XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 0
UTF-8
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Security.Principal;
using System.Text;
using System.Text.Json;
using XFEEliteDangerous.Core;
using XFEEliteDangerous.Client;
using XFEEliteDangerous.OnlineClient;

namespace XFEEliteDangerous.Bootstrapper;

/// <summary>Owns only the exact E-copy child. Its AppContainer has zero network capabilities.</summary>
internal sealed class OwnedOfflineGame : IAsyncDisposable
{
    internal const string Executable = @"E:\Elite Dangerous\Products\elite-dangerous-odyssey-64\EliteDangerous64.exe";
    private const string ImageHash = "e6be8bbe04e6a7ae226d4318945af7f367de13dc5a007a261964d9ba8144e988";
    private readonly List<IDisposable> leases = [];
    private FileStream? executableLock;
    private Native.ProcessInfo process;
    private nint sid,job;
    private OfflineNativeModule? module;
    private bool stopped;
    private OfflineDiagnosticLog? diagnostics;
    private OnlineBridgeHost? onlineBridge;
    private OnlineConnection? onlineConnection;
    internal string RuntimeDirectory { get; private set; } = "";
    internal uint ProcessId => process.ProcessId;
    internal static async Task<OwnedOfflineGame> CreateAsync(string workspace, string accountId, string library, bool visible,bool probeSolo,OnlineConnection? online=null)
    {
        var game = new OwnedOfflineGame();
        try { game.onlineConnection=online;await game.InitializeAsync(workspace, accountId, library, visible,probeSolo); return game; }
        catch(Exception error) { game.diagnostics?.Write("error","bootstrapper","launch.failed","Owned offline launch failed.",exception:error);await game.DisposeAsync(); throw; }
    }
    private async Task InitializeAsync(string workspace, string accountId, string library, bool visible,bool probeSolo)
    {
        RuntimeDirectory=Path.Combine(SafePaths.ValidateArtifactRoot(null),"offline-game-"+Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(RuntimeDirectory);
        var logs=Path.Combine(RuntimeDirectory,"logs");Directory.CreateDirectory(logs);
        diagnostics=new(logs,"bootstrapper",Path.GetFileName(RuntimeDirectory));
        OfflineDiagnostics.Current=diagnostics;
        diagnostics.Write("info","bootstrapper","launch.begin","Preparing owned offline process.",
            new Dictionary<string,string?>{["visible"]=visible.ToString(),["build"]="2026.09.03.332841",["requestedContentMode"]="Odyssey"});
        SafePaths.NoReparseAncestors(Executable); SafePaths.NoReparseAncestors(library); SafePaths.NoReparseAncestors(workspace);
        if(onlineConnection is null){await using var store = new StateStore(workspace);await store.Read(state =>
        {
            if (!state.Accounts.TryGetValue(accountId,out var account)||account.Disabled||account.NativeSave is null)
                throw new InvalidDataException("Select an enabled local account with a complete native save.");
            account.NativeSave.ValidateCurrent(account);return true;
        });}
        if (onlineConnection is null&&System.Diagnostics.Process.GetProcessesByName("EliteDangerous64").Any(value =>
        {
            using (value) { try { return string.Equals(value.MainModule?.FileName, Executable, StringComparison.OrdinalIgnoreCase); } catch { return true; } }
        })) throw new InvalidOperationException("The E-copy game is already running; it was left untouched.");
        executableLock = new(Executable,FileMode.Open,FileAccess.Read,FileShare.Read);
        if(executableLock.Length!=103469056||Convert.ToHexStringLower(await SHA256.HashDataAsync(executableLock))!=ImageHash)
            throw new InvalidDataException("The E-copy version differs from the source-audited build.");
        diagnostics.Write("info","bootstrapper","image.verified","Game image length and pinned hash verified.");
        var payload=Path.Combine(RuntimeDirectory,"XFEEliteDangerous.OfflineRuntime.dll");File.Copy(library,payload,false);
        var symbols=Path.ChangeExtension(library,"pdb");
        if(File.Exists(symbols))File.Copy(symbols,Path.ChangeExtension(payload,"pdb"),false);
        diagnostics.Write("info","bootstrapper","runtime.copied","Native runtime and available symbols copied.");
        var profile=onlineConnection is null?PersistentGameProfile.Open(@"E:\Elite Dangerous"):PersistentGameProfile.OpenOnline(@"E:\Elite Dangerous",onlineConnection.Server,accountId);leases.Add(profile);
        profile.ImportPreviousVisibleSettings();
        foreach(var name in new[]{"LocalAppData","RoamingAppData","SavedGames\\Frontier Developments\\Elite Dangerous","Temp","RenderCache"})
            Directory.CreateDirectory(Path.Combine(profile.DirectoryPath,name));
        _=await OwnedProfileSettings.InitializePersistentAsync(profile);
        sid=profile.CreateOrReuseAppContainer();var sidText=new SecurityIdentifier(sid).Value;
        Grant(RuntimeDirectory,sidText,FileSystemRights.ReadAndExecute);Grant(logs,sidText,FileSystemRights.Modify);
        Grant(profile.DirectoryPath,sidText,FileSystemRights.Modify);
        // The selected workspace is owned local data. Native commits and the desktop use short file leases.
        // ReplaceFileW merges the old file's DACL onto the new file. WRITE_DAC
        // is needed as well as ordinary writes for StateStore's atomic replace.
        if(onlineConnection is null)Grant(workspace,sidText,FileSystemRights.Modify|FileSystemRights.ChangePermissions);
        leases.Add(new DirectoryAccessLease(@"E:\Elite Dangerous",sidText,Path.Combine(RuntimeDirectory,"game-access.json")));
        var desktop=new PrivateDesktopLease(sidText,Guid.NewGuid().ToString("N"),visible);leases.Add(desktop);
        job=Native.CreateJobObjectW(0,null);if(job==0)throw new Win32Exception();
        var limits=new Native.ExtendedLimit{Basic=new Native.BasicLimit{Flags=0x2000|0x400|8,ActiveProcesses=1}};
        if(!Native.SetInformationJobObject(job,9,ref limits,(uint)Marshal.SizeOf<Native.ExtendedLimit>()))throw new Win32Exception();
        nint attributes=0,capabilities=0,environment=0;
        try
        {
            nuint size=0;Native.InitializeProcThreadAttributeList(0,1,0,ref size);
            attributes=Marshal.AllocHGlobal((int)size);if(!Native.InitializeProcThreadAttributeList(attributes,1,0,ref size))throw new Win32Exception();
            capabilities=Marshal.AllocHGlobal(Marshal.SizeOf<Native.SecurityCapabilities>());
            Marshal.StructureToPtr(new Native.SecurityCapabilities{AppContainerSid=sid,Capabilities=0,CapabilityCount=0},capabilities,false);
            if(!Native.UpdateProcThreadAttribute(attributes,0,0x00020009,capabilities,(nuint)Marshal.SizeOf<Native.SecurityCapabilities>(),0,0))throw new Win32Exception();
            var startup=new Native.StartupEx{Startup=new Native.Startup{Size=Marshal.SizeOf<Native.StartupEx>(),Desktop=desktop.StartupName},Attributes=attributes};
            var zeros=new string('0',64);
            var command=new StringBuilder('"'+Executable+"\" \"ServerToken "+zeros+" "+zeros+" \" /novr");
            environment=Marshal.StringToHGlobalUni(EnvironmentBlock(profile.DirectoryPath));
            if(!Native.CreateProcessW(Executable,command,0,0,false,Native.Suspended|Native.ExtendedStartup|0x400,environment,Path.GetDirectoryName(Executable)!,ref startup,out process))throw new Win32Exception();
            if(!Native.AssignProcessToJobObject(job,process.Process))throw new Win32Exception();
        }
        finally
        {
            if(environment!=0)Marshal.FreeHGlobal(environment);
            if(attributes!=0){Native.DeleteProcThreadAttributeList(attributes);Marshal.FreeHGlobal(attributes);}
            if(capabilities!=0)Marshal.FreeHGlobal(capabilities);
        }
        VerifyNoNetworkCapabilities();
        diagnostics.Write("info","bootstrapper","process.created","Owned suspended process has zero network capabilities.",
            new Dictionary<string,string?>{["gamePid"]=ProcessId.ToString(),["zeroNetworkCapabilities"]="true"});
        string? bridgeDirectory=null;
        if(onlineConnection is not null)
        {
            bridgeDirectory=Path.Combine(logs,"online-bridge");onlineBridge=new(bridgeDirectory,onlineConnection);
            diagnostics.Write("info","bootstrapper","online.connected","Launcher owns the pinned private server connection; native game retains zero network capabilities.",
                new Dictionary<string,string?>{["server"]=onlineConnection.Server.Address,["account"]=accountId});
        }
        var configuration=JsonSerializer.SerializeToUtf8Bytes(new { WorkspaceDirectory=workspace,AccountId=accountId,Executable,
            ProfileRoot=profile.DirectoryPath,LogDirectory=logs,DriverVersion=await DisplayDriverVersionReader.ReadAsync()??"",ProbeSolo=probeSolo,OnlineBridgeDirectory=bridgeDirectory });
        module=await OfflineNativeModule.LoadAsync(process.Process,payload,configuration.Concat(new byte[]{0}).ToArray());
        if(await module.ReadStatusAsync()!=3)throw new InvalidDataException("The offline runtime is not ready.");
        diagnostics.Write("info","bootstrapper","runtime.initialized","Injected local runtime reported initialized.");
        File.WriteAllText(Path.Combine(RuntimeDirectory,"launch.json"),JsonSerializer.Serialize(new { ProcessId,mode="in-process-offline",
            nativeSoloValidated=false,nativeManagementSchema=1,requestedContentMode="Odyssey",zeroNetworkCapabilities=true,visible,profile=profile.DirectoryPath,workspace,accountId,
            imageSha256=ImageHash,ownedNativeRuntime=payload,online=onlineConnection is not null,server=onlineConnection?.Server.Address },new JsonSerializerOptions{WriteIndented=true}));
    }
    internal void Resume()
    {
        if(module is null||stopped)throw new InvalidOperationException("The owned runtime is not initialized.");
        if(Native.ResumeThread(process.Thread)!=1)throw new Win32Exception();
        diagnostics?.Write("info","bootstrapper","process.resumed","Owned game thread resumed.",new Dictionary<string,string?>{["gamePid"]=ProcessId.ToString()});
    }
    internal bool HasExited => process.Process==0||Native.WaitForSingleObject(process.Process,0)==0;
    internal Task<ulong> TransfersAsync()=>module?.TransfersAsync()??Task.FromResult(0UL);
    internal async Task WaitAsync(CancellationToken cancellation)
    {
        while(!HasExited){cancellation.ThrowIfCancellationRequested();await Task.Delay(500,cancellation);}
        if(process.Process!=0&&Native.GetExitCodeProcess(process.Process,out var code))
            diagnostics?.Write(code==0?"info":"error","bootstrapper","process.exited","Owned game process exited.",
                new Dictionary<string,string?>{["gamePid"]=ProcessId.ToString(),["exitCode"]="0x"+code.ToString("X8")});
    }
    private void VerifyNoNetworkCapabilities()
    {
        if(!Native.OpenProcessToken(process.Process,8,out var token))throw new Win32Exception();
        try
        {
            if(TokenInt(29)!=1||TokenInt(30)!=0)throw new InvalidDataException("The owned process is not a zero-capability AppContainer.");
            Native.GetTokenInformation(token,31,0,0,out var size);var buffer=Marshal.AllocHGlobal((int)size);
            try{if(!Native.GetTokenInformation(token,31,buffer,size,out _))throw new Win32Exception();
                if(new SecurityIdentifier(Marshal.ReadIntPtr(buffer)).Value!=new SecurityIdentifier(sid).Value)throw new InvalidDataException("The owned container identity differs.");}
            finally{Marshal.FreeHGlobal(buffer);}
            int TokenInt(int kind)
            {
                Native.GetTokenInformation(token,kind,0,0,out var needed);var data=Marshal.AllocHGlobal((int)needed);
                try{if(!Native.GetTokenInformation(token,kind,data,needed,out _))throw new Win32Exception();return Marshal.ReadInt32(data);}
                finally{Marshal.FreeHGlobal(data);}
            }
        }
        finally{Native.CloseHandle(token);}
    }
    private static void Grant(string path,string sid,FileSystemRights rights)
    {
        SafePaths.NoReparseAncestors(path);var info=new DirectoryInfo(path);var access=info.GetAccessControl();
        access.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(sid),rights,InheritanceFlags.ContainerInherit|InheritanceFlags.ObjectInherit,PropagationFlags.None,AccessControlType.Allow));info.SetAccessControl(access);
    }
    private static string EnvironmentBlock(string profile)
    {
        var variables=new SortedDictionary<string,string>(StringComparer.OrdinalIgnoreCase);
        foreach(var name in new[]{"SystemRoot","WINDIR","SystemDrive","ComSpec","PROCESSOR_ARCHITECTURE","PROCESSOR_IDENTIFIER","NUMBER_OF_PROCESSORS","OS","PATHEXT"})
            if(Environment.GetEnvironmentVariable(name) is {} value)variables[name]=value;
        var windows=Environment.GetFolderPath(Environment.SpecialFolder.Windows);variables["PATH"]=windows+"\\System32;"+windows;
        variables["USERPROFILE"]=profile;variables["APPDATA"]=Path.Combine(profile,"RoamingAppData");variables["LOCALAPPDATA"]=Path.Combine(profile,"LocalAppData");
        variables["TEMP"]=variables["TMP"]=Path.Combine(profile,"Temp");return string.Join('\0',variables.Select(value=>value.Key+"="+value.Value))+"\0\0";
    }
    public async ValueTask DisposeAsync()
    {
        if(stopped)return;stopped=true;
        if(process.Process!=0&&!HasExited){diagnostics?.Write("info","bootstrapper","process.owner.stop","Diagnostic owner is stopping its game process.",
            new Dictionary<string,string?>{["gamePid"]=ProcessId.ToString()});Native.TerminateProcess(process.Process,0);while(Native.WaitForSingleObject(process.Process,50)==258)await Task.Delay(10);}
        if(process.Thread!=0)Native.CloseHandle(process.Thread);if(process.Process!=0)Native.CloseHandle(process.Process);if(job!=0)Native.CloseHandle(job);
        if(onlineBridge is not null)await onlineBridge.DisposeAsync();
        if(onlineConnection is not null)await onlineConnection.DisposeAsync();
        foreach(var lease in leases.AsEnumerable().Reverse())lease.Dispose();
        if(sid!=0)Native.FreeSid(sid);executableLock?.Dispose();
        diagnostics?.Write("info","bootstrapper","launch.disposed","Owned launch resources released.");diagnostics?.Dispose();
        if(ReferenceEquals(OfflineDiagnostics.Current,diagnostics))OfflineDiagnostics.Current=null;
    }
}