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

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text.Json;
using XFEEDToolBox.Core.Execution;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using XFEEDToolBox.Core.Plugins;
using XFEEDToolBox.Core.Session;

namespace XFEEDToolBox.Services;

public sealed class WindowsTradingBootstrap(Func<PlayerState> player,string executable,string packageDirectory,string? operationDirectory=null)
{
    public bool ReconcileOriginalOrders{get;init;}
    public bool ResumeExistingCargo{get;init;}
    public GameSessionTarget? ExpectedConnectionFailureClient{get;init;}
    public FlightConnectionBinding? ExpectedFlightConnection{get;init;}
    private bool originalFailedClientExited;
    public event Action<string>? Progress;
    public Task UpgradeAndEnterAsync(CancellationToken token)
    {
        var expected=player();var lifecycle=new WindowsGameSessionDriver(player,()=>executable);
        var dumpDirectory=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),"AppData","Local","Temp","Frontier Developments","EliteDangerous","CrashDumps");
        Task AttemptAsync(Action<GameSessionTarget> connectionOwner,CancellationToken cancellation)=>StartupCrashRecovery.RunAsync(
            (owned,working)=>UpgradeAndEnterCoreAsync(expected,client=>{connectionOwner(client);owned(client);},working,
                originalFailedClientExited?null:ExpectedConnectionFailureClient),lifecycle.HasExitedAsync,
            target=>StartupCrashRecovery.Find(dumpDirectory,target,DateTimeOffset.UtcNow),
            async(crash,count,crashCancellation)=>
            {
                Progress?.Invoke($"已确认新启动客户端崩溃(0x{crash.ExceptionCode:x8}),正在通过官方启动器恢复原模式({count}/2)。凭据:{crash.Path}");
                await OwnedStartupCrashReporter.CloseAsync(executable,crash,crashCancellation).ConfigureAwait(false);
            },cancellation);
        return StartupConnectionRecovery.RunAsync(AttemptAsync,async(client,failure,owned,count,cancellation)=>
        {
            await RecoverConnectionFailureAsync(client,expected,lifecycle,owned,count,cancellation).ConfigureAwait(false);
            // Only the fresh-evidence recovery above may release this exact
            // inherited instance binding, after its real exit was observed.
            originalFailedClientExited=true;
        },token,allowVerifiedExisting:true);
    }
    private async Task UpgradeAndEnterCoreAsync(PlayerState expected,Action<GameSessionTarget> ownLaunch,CancellationToken token,GameSessionTarget? expectedFailedClient=null)
    {
        var lifecycle=new WindowsGameSessionDriver(player,()=>executable);
        var nextNoticeCheck=DateTimeOffset.MinValue;
        async Task VerifyEntryAsync(GameSessionTarget client,CancellationToken cancellation)
        {
            if(await lifecycle.HasExitedAsync(client,cancellation).ConfigureAwait(false))throw new StartupClientExitedException(client.ProcessId);
        }
        Task PrepareEntryAsync(GameSessionTarget client,CancellationToken cancellation)
        {
            cancellation.ThrowIfCancellationRequested();
            if(DateTimeOffset.UtcNow<nextNoticeCheck)return Task.CompletedTask;
            nextNoticeCheck=DateTimeOffset.UtcNow.AddSeconds(1);
            if(KnownTranslationStartupNotice.TryDismiss(client,cancellation))
                Progress?.Invoke("已核对并关闭当前客户端的汉化加载提示,正在继续原始主菜单载入。");
            return Task.CompletedTask;
        }
        var target=await lifecycle.FindLaunchTargetAsync(token).ConfigureAwait(false);
        if(expectedFailedClient is {} failedClient&&(target.ProcessId!=failedClient.ProcessId||target.StartedUtcTicks!=failedClient.StartedUtcTicks
            ||!string.Equals(Path.GetFullPath(target.Executable),Path.GetFullPath(failedClient.Executable),StringComparison.OrdinalIgnoreCase)))
            throw new InvalidDataException("飞行连接恢复的原实例已改变,未接管另一个游戏客户端。");
        var original=expected;var mode=original.GameMode;var commander=original.CommanderId;
        if(!NativeRelogWorkflow.SupportsMode(mode))throw new NotSupportedException("请先确认原来的单人或公开游戏模式。");
        await lifecycle.ValidateLaunchAsync(target,token).ConfigureAwait(false);
        if(target.ProcessId>0&&await lifecycle.HasExitedAsync(target,token))target=target with{ProcessId=0,StartedUtcTicks=0};
        if(target.ProcessId>0)
        {
            try{await ThrowForFailedConnectionAsync(target,original,lifecycle,token).ConfigureAwait(false);}
            catch(StartupClientExitedException)
            {
                if(!await lifecycle.HasExitedAsync(target,token))throw;
                target=target with{ProcessId=0,StartedUtcTicks=0};
            }
        }
        target=await RecoverStalledSceneAsync(target,original,lifecycle,token).ConfigureAwait(false);
        if(target.ProcessId>0)
        {
            var existing=await GamePluginManager.ProbeAsync(target.ProcessId,token:token).ConfigureAwait(false);
            if(existing.Version==new GamePluginManager(packageDirectory).Package().Version&&!ReconcileOriginalOrders)
            {
                if(!existing.ObservationStarted)await GamePluginManager.StartNavigationObservationAsync(target.ProcessId,token).ConfigureAwait(false);
                var started=new DateTimeOffset(new DateTime(target.StartedUtcTicks,DateTimeKind.Utc));
                var actual=await new NativeRelogChannel(target.ProcessId,NativeRelogChannel.SupportedGameSha256,c=>VerifyEntryAsync(target,c),entryOnly:true).ReadEntryStatusAsync(token);
                if(!NativeSessionStartupReadiness.Ready(player(),actual,DateTimeOffset.UtcNow,started))
                {
                    Progress?.Invoke("新版插件已加载,正在恢复现有游戏进程");
                    var entry=new NativeSessionEntryWorkflow(new NativeRelogChannel(target.ProcessId,NativeRelogChannel.SupportedGameSha256,c=>VerifyEntryAsync(target,c),entryOnly:true,prepareEntry:c=>PrepareEntryAsync(target,c)),player,mode,commander)
                        {ProcessStartedAt=started,ExpectedShipId=original.CurrentShipId};
                    entry.Progress+=text=>Progress?.Invoke(text);
                    try{await entry.RunAsync(token).ConfigureAwait(false);}catch(NativeSessionConnectionFailedException failure){throw new NativeSessionConnectionFailedException(failure.Frame,target);}
                }
                if(player().CommanderId!=commander||player().CurrentShipId!=original.CurrentShipId||!ResumeExistingCargo&&player().CargoCount!=0||player().GameMode!=mode||!player().CargoReliable
                    ||ResumeExistingCargo&&player().CargoCount!=original.CargoCount)
                    throw new InvalidDataException("恢复后的指挥官、飞船、空舱或游戏模式不一致,未继续自动贸易。");
                return;
            }
        }
        if(target.ProcessId>0)
        {
            if(original.InGame&&(!original.CargoReliable||!ReconcileOriginalOrders&&original.CargoCount!=0||original.CurrentShipId is null))
                throw new InvalidOperationException("更新前需要确认停靠和空货舱;当前游戏已保留。");
            if(original.InGame&&!original.Docked)
            {
                Progress?.Invoke("正在连续确认空舱、静止、无威胁及原生健康状态,再通过原始菜单更新插件");
                var gate=new SessionMaintenanceFlightGate(original);var pipe=new NavigationPipeClient(target.ProcessId);
                var maintenanceUntil=DateTimeOffset.UtcNow.AddSeconds(30);
                while(true)
                {
                    var raw=await pipe.CallAsync(new{op="read"},token);var local=await pipe.CallAsync(new{op="local.read"},token);
                    if(gate.Observe(player(),raw,local,DateTimeOffset.UtcNow))break;
                    if(DateTimeOffset.UtcNow>=maintenanceUntil)throw new InvalidOperationException("未连续确认可更新的空舱静止状态,当前游戏已保留。");
                    await Task.Delay(100,token);
                }
            }
            if(operationDirectory is not null)
                await NativeUnsubmittedTradeIntents.ReconcileAsync(operationDirectory,new NativeCommerceChannel(target.ProcessId),original,token).ConfigureAwait(false);
            using var game=Process.GetProcessById(target.ProcessId);
            if(game.StartTime.ToUniversalTime().Ticks!=target.StartedUtcTicks||!string.Equals(game.MainModule?.FileName,target.Executable,StringComparison.OrdinalIgnoreCase))
                throw new InvalidOperationException("准备更新的游戏实例已改变。");
            Progress?.Invoke("正在通过游戏原始菜单退出当前会话以更新插件");
            var recovery=new NativeSessionRecoveryChannel(target.ProcessId,NativeRelogChannel.SupportedGameSha256,Path.Combine(packageDirectory,"DockingControl"));
            var owner=Guid.NewGuid().ToString("N");var endedAtMenu=false;
            recovery.Progress+=text=>Progress?.Invoke(text);
            try
            {
                await recovery.BeginAsync(owner,token).ConfigureAwait(false);
                var frame=await recovery.ReadAsync(owner,token).ConfigureAwait(false);
                SessionMaintenanceExit.RequireMainMenu(frame,DateTimeOffset.UtcNow);
                Progress?.Invoke("已确认游戏主菜单,正在结束客户端以加载新版插件");
                await lifecycle.TerminateAsync(target,token).ConfigureAwait(false);
                endedAtMenu=true;
            }
            finally{await SessionMaintenanceExit.ReleaseAfterExitAsync(()=>recovery.EndAsync(owner),()=>endedAtMenu&&game.HasExited).ConfigureAwait(false);}
            using var menuDeadline=CancellationTokenSource.CreateLinkedTokenSource(token);menuDeadline.CancelAfter(TimeSpan.FromSeconds(15));
            await game.WaitForExitAsync(menuDeadline.Token).ConfigureAwait(false);
        }
        var manager=new GamePluginManager(packageDirectory);
        if(!ReconcileOriginalOrders)
        {
            Progress?.Invoke("正在安装已验证的通用原生插件");
            var installed=await manager.InstallAsync(executable,token).ConfigureAwait(false);
            if(!installed.Installed||installed.Installation?.Version!=manager.Package().Version)throw new IOException("原生插件安装结果未核对。");
        }
        await lifecycle.LaunchAsync(target,token).ConfigureAwait(false);
        GameSessionTarget? replacement=null;var until=DateTimeOffset.UtcNow.AddMinutes(3);
        while(replacement is null&&DateTimeOffset.UtcNow<until)
        {replacement=await lifecycle.FindReplacementAsync(target,token).ConfigureAwait(false);if(replacement is null)await Task.Delay(150,token);}
        if(replacement is null)throw new TimeoutException("官方启动器尚未建立新的游戏实例。");
        var current=replacement;ownLaunch(current);until=DateTimeOffset.UtcNow.AddMinutes(4);
        while(true)
        {
            token.ThrowIfCancellationRequested();
            await VerifyEntryAsync(current,token).ConfigureAwait(false);
            await PrepareEntryAsync(current,token).ConfigureAwait(false);
            try
            {
                var hello=await GamePluginManager.ProbeAsync(current.ProcessId,token:token).ConfigureAwait(false);
                if(hello.Version!=manager.Package().Version)throw new InvalidDataException("新进程没有加载刚安装的插件版本。");
                if(!hello.ObservationStarted)hello=await GamePluginManager.StartNavigationObservationAsync(current.ProcessId,token).ConfigureAwait(false);
                if(hello.ObservationStarted&&hello.SupportsRelog)break;
            }
            catch(Exception error)when(!token.IsCancellationRequested&&!NavigationRecovery.HasInvalidDataCause(error)&&error is IOException or OperationCanceledException){}
            if(DateTimeOffset.UtcNow>=until)throw new TimeoutException("新游戏进程的原生菜单接口未就绪。");
            await Task.Delay(100,token).ConfigureAwait(false);
        }
        var startedCurrent=new DateTimeOffset(new DateTime(current.StartedUtcTicks,DateTimeKind.Utc));
        var nativeCurrent=await new NativeRelogChannel(current.ProcessId,NativeRelogChannel.SupportedGameSha256,c=>VerifyEntryAsync(current,c),entryOnly:true).ReadEntryStatusAsync(token);
        if(!NativeSessionStartupReadiness.Ready(player(),nativeCurrent,DateTimeOffset.UtcNow,startedCurrent))
        {
            var entry=new NativeSessionEntryWorkflow(new NativeRelogChannel(current.ProcessId,NativeRelogChannel.SupportedGameSha256,c=>VerifyEntryAsync(current,c),entryOnly:true,prepareEntry:c=>PrepareEntryAsync(current,c)),player,mode,commander)
                {ProcessStartedAt=startedCurrent,ExpectedShipId=original.CurrentShipId};
            entry.Progress+=text=>Progress?.Invoke(text);
            try{await entry.RunAsync(token).ConfigureAwait(false);}catch(NativeSessionConnectionFailedException failure){throw new NativeSessionConnectionFailedException(failure.Frame,current);}
        }
        if(player().CommanderId!=commander||player().CurrentShipId!=original.CurrentShipId||!ResumeExistingCargo&&!ReconcileOriginalOrders&&player().CargoCount!=0||player().GameMode!=mode||!player().CargoReliable
            ||ResumeExistingCargo&&player().CargoCount!=original.CargoCount)
            throw new InvalidDataException("恢复后的指挥官、飞船、空舱或游戏模式不一致,未继续自动贸易。");
        Progress?.Invoke("已进入原游戏模式,正在核对原行程。");
    }
    private static void RequireOriginalIdentity(PlayerState expected,PlayerState current)
    {
        if(current.CommanderId.Length>0&&current.CommanderId!=expected.CommanderId
            ||current.CurrentShipId is {} ship&&ship!=expected.CurrentShipId||current.GameMode.Length>0&&current.GameMode!=expected.GameMode)
            throw new InvalidDataException("连接失败恢复期间身份、飞船或原模式改变,当前客户端已保留。");
    }
    private void RequireConnectionRecoveryIdentity(PlayerState expected,CancellationToken token)
    {
        token.ThrowIfCancellationRequested();var current=player();RequireOriginalIdentity(expected,current);
        // Before the inherited failed process has really exited, no new
        // Journal session or changed cargo can replace the original flight.
        // Its official replacement is validated as a new session on entry.
        if(!originalFailedClientExited&&ExpectedFlightConnection is {} binding)
            FlightConnectionRecovery.RequireCurrentIdentity(binding,current,token);
    }
    private async Task ThrowForFailedConnectionAsync(GameSessionTarget client,PlayerState expected,WindowsGameSessionDriver lifecycle,CancellationToken token)
    {
        var observer=new NativeConnectionFailureObserver();var until=DateTimeOffset.UtcNow.AddSeconds(5);
        var channel=new NativeRelogChannel(client.ProcessId,NativeRelogChannel.SupportedGameSha256,c=>lifecycle.ValidateExitAsync(client,c),entryOnly:true);
        while(DateTimeOffset.UtcNow<until)
        {
            RequireConnectionRecoveryIdentity(expected,token);NativeSessionFrame frame;
            try{frame=await channel.ReadEntryStatusAsync(token);}
            catch(Exception error)when(!token.IsCancellationRequested&&error is not StartupClientExitedException&&!NavigationRecovery.HasInvalidDataCause(error)&&error is IOException or InvalidOperationException or OperationCanceledException){return;}
            if(!frame.Available||!NativeMenuExitSafety.Fresh(frame,DateTimeOffset.UtcNow)||!frame.Stable||frame.State!="FailedToConnect")return;
            if(observer.Observe(frame,DateTimeOffset.UtcNow))throw new NativeSessionConnectionFailedException(frame,client);
            await Task.Delay(100,token);
        }
    }
    private async Task RecoverConnectionFailureAsync(GameSessionTarget client,PlayerState expected,WindowsGameSessionDriver lifecycle,bool owned,int count,CancellationToken token)
    {
        await lifecycle.ValidateRestartAsync(client,token);
        await using(var image=File.OpenRead(client.Executable))
            if(Convert.ToHexStringLower(await SHA256.HashDataAsync(image,token))!=NativeRelogChannel.SupportedGameSha256)
                throw new InvalidDataException("连接失败客户端的游戏构建不匹配,未重启。");
        var pipe=new NavigationPipeClient(client.ProcessId);var observer=new NativeConnectionFailureObserver();var until=DateTimeOffset.UtcNow.AddSeconds(5);
        JsonElement lastMenu=default,lastNavigation=default;
        while(true)
        {
            RequireConnectionRecoveryIdentity(expected,token);await lifecycle.ValidateExitAsync(client,token);
            lastMenu=await pipe.CallAsync(new{op="session.status"},token);lastNavigation=await pipe.CallAsync(new{op="describe"},token);
            RequireConnectionRecoveryIdentity(expected,token);await lifecycle.ValidateExitAsync(client,token);
            if(!NativeSessionStartupReadiness.FailedWithoutLiveFlight(lastMenu,lastNavigation,client.ProcessId,NativeRelogChannel.SupportedGameSha256,DateTimeOffset.UtcNow))
                throw new InvalidDataException("原版连接失败或无驾驶场景的证据已改变,未重启客户端。");
            if(observer.Observe(NativeRelogChannel.Parse(lastMenu),DateTimeOffset.UtcNow))break;
            if(DateTimeOffset.UtcNow>=until)throw new TimeoutException("未连续确认连接失败,客户端已保留。");
            await Task.Delay(100,token);
        }
        var directory=operationDirectory??Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),"XFEstudio","XFEEDToolBox","logs","startup-recovery");
        Directory.CreateDirectory(directory);
        var evidence=new{at=DateTimeOffset.UtcNow,ownership=owned?"owned-new":"adopted-verified-failed-client",client,count,expected.CommanderId,expected.CurrentShipId,expected.GameMode,
            originalOrdersUnchanged=true,session=lastMenu,navigation=lastNavigation};
        await File.WriteAllTextAsync(Path.Combine(directory,$"connection-recovery-{client.ProcessId}-{client.StartedUtcTicks}-{count}.json"),JsonSerializer.Serialize(evidence),token);
        RequireConnectionRecoveryIdentity(expected,token);await lifecycle.ValidateExitAsync(client,token);
        Progress?.Invoke($"原版连接失败已持续确认,正在通过官方启动器恢复原模式({count}/2);保留原货物与交易回执。");
        await StartupConnectionRecovery.TerminateWithFreshEvidenceAsync(async cancellation=>
        {
            RequireConnectionRecoveryIdentity(expected,cancellation);await lifecycle.ValidateExitAsync(client,cancellation);
            var menu=await pipe.CallAsync(new{op="session.status"},cancellation);
            var navigation=await pipe.CallAsync(new{op="describe"},cancellation);
            RequireConnectionRecoveryIdentity(expected,cancellation);await lifecycle.ValidateExitAsync(client,cancellation);
            return NativeSessionStartupReadiness.FailedWithoutLiveFlight(menu,navigation,client.ProcessId,NativeRelogChannel.SupportedGameSha256,DateTimeOffset.UtcNow);
        },cancellation=>lifecycle.TerminateAsync(client,cancellation),token);
        var exitUntil=DateTimeOffset.UtcNow.AddSeconds(15);
        while(!await lifecycle.HasExitedAsync(client,token))
        {if(DateTimeOffset.UtcNow>=exitUntil)throw new TimeoutException("连接失败客户端尚未确认退出,未启动第二个实例。");await Task.Delay(100,token);}
    }
    internal static async Task<bool> HasSavingSceneAsync(int pid,CancellationToken token)
        =>await SavingSceneRecovery.ReadAsync(pid,NativeRelogChannel.SupportedGameSha256,token).ConfigureAwait(false);
    private async Task<GameSessionTarget> RecoverStalledSceneAsync(GameSessionTarget target,PlayerState expected,WindowsGameSessionDriver lifecycle,CancellationToken token)
    {
        if(target.ProcessId<=0||!await HasSavingSceneAsync(target.ProcessId,token).ConfigureAwait(false))return target;
        var began=DateTimeOffset.UtcNow;
        Progress?.Invoke("游戏正在保存抵达位置;保持原航程并观察原版场景,暂不发送驾驶或交易指令。");
        while(DateTimeOffset.UtcNow-began<TimeSpan.FromSeconds(90))
        {
            token.ThrowIfCancellationRequested();var current=player();
            if(current.CommanderId!=expected.CommanderId||current.CurrentShipId!=expected.CurrentShipId||current.GameMode!=expected.GameMode||current.CargoCount!=expected.CargoCount)
                throw new InvalidOperationException("保存场景等待期间身份、飞船或货舱变化,未自动重启。");
            if(!await HasSavingSceneAsync(target.ProcessId,token).ConfigureAwait(false))return target;
            await Task.Delay(1000,token).ConfigureAwait(false);
        }
        // No gameplay is permitted by this original transition state. Exiting
        // the verified client does not authorize another order; official entry
        // below must re-establish commander, ship, mode and actual cargo first.
        Progress?.Invoke("原版保存抵达位置持续 90 秒未完成,正在重启官方客户端;原订单和回执保持不变。");
        await lifecycle.TerminateAsync(target,token).ConfigureAwait(false);
        var until=DateTimeOffset.UtcNow.AddSeconds(15);
        while(!await lifecycle.HasExitedAsync(target,token).ConfigureAwait(false))
        {if(DateTimeOffset.UtcNow>=until)throw new TimeoutException("原客户端尚未确认退出,未启动新实例。");await Task.Delay(100,token).ConfigureAwait(false);}
        // A terminated process can briefly remain in process enumeration. Keep
        // its verified installation, but never probe that dead pipe again.
        return target with{ProcessId=0,StartedUtcTicks=0};
    }
}