diff --git a/src/XIVLauncher2.Common.Unix/Compatibility/CompatibilityTools.cs b/src/XIVLauncher2.Common.Unix/Compatibility/CompatibilityTools.cs new file mode 100644 index 0000000..48e59e2 --- /dev/null +++ b/src/XIVLauncher2.Common.Unix/Compatibility/CompatibilityTools.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Serilog; +using XIVLauncher2.Common.Util; + +#if FLATPAK +#warning THIS IS A FLATPAK BUILD!!! +#endif + +namespace XIVLauncher2.Common.Unix.Compatibility; + +public class CompatibilityTools +{ + private DirectoryInfo toolDirectory; + private DirectoryInfo dxvkDirectory; + + private StreamWriter logWriter; + +#if WINE_XIV_ARCH_LINUX + private const string WINE_XIV_RELEASE_URL = "https://github.com/goatcorp/wine-xiv-git/releases/download/7.10.r3.g560db77d/wine-xiv-staging-fsync-git-arch-7.10.r3.g560db77d.tar.xz"; +#elif WINE_XIV_FEDORA_LINUX + private const string WINE_XIV_RELEASE_URL = "https://github.com/goatcorp/wine-xiv-git/releases/download/7.10.r3.g560db77d/wine-xiv-staging-fsync-git-fedora-7.10.r3.g560db77d.tar.xz"; +#else + private const string WINE_XIV_RELEASE_URL = "https://github.com/goatcorp/wine-xiv-git/releases/download/7.10.r3.g560db77d/wine-xiv-staging-fsync-git-ubuntu-7.10.r3.g560db77d.tar.xz"; +#endif + private const string WINE_XIV_RELEASE_NAME = "wine-xiv-staging-fsync-git-7.10.r3.g560db77d"; + + public bool IsToolReady { get; private set; } + + public WineSettings Settings { get; private set; } + + private string WineBinPath => Settings.StartupType == WineStartupType.Managed ? + Path.Combine(toolDirectory.FullName, WINE_XIV_RELEASE_NAME, "bin") : + Settings.CustomBinPath; + private string Wine64Path => Path.Combine(WineBinPath, "wine64"); + private string WineServerPath => Path.Combine(WineBinPath, "wineserver"); + + public bool IsToolDownloaded => File.Exists(Wine64Path) && Settings.Prefix.Exists; + + public DxvkSettings DxvkSettings { get; private set; } + + private readonly bool gamemodeOn; + + public CompatibilityTools(WineSettings wineSettings, DxvkSettings dxvkSettings, bool? gamemodeOn, DirectoryInfo toolsFolder) + { + this.Settings = wineSettings; + this.DxvkSettings = dxvkSettings; + this.gamemodeOn = gamemodeOn ?? false; + this.toolDirectory = new DirectoryInfo(Path.Combine(toolsFolder.FullName, "beta")); + this.dxvkDirectory = new DirectoryInfo(Path.Combine(toolsFolder.FullName, "dxvk")); + + this.logWriter = new StreamWriter(wineSettings.LogFile.FullName); + + if (wineSettings.StartupType == WineStartupType.Managed) + { + if (!this.toolDirectory.Exists) + this.toolDirectory.Create(); + + if (!this.dxvkDirectory.Exists) + this.dxvkDirectory.Create(); + } + } + + public async Task EnsureTool(DirectoryInfo tempPath) + { + if (!File.Exists(Wine64Path)) + { + Log.Information("Compatibility tool does not exist, downloading"); + await DownloadTool(tempPath).ConfigureAwait(false); + } + + EnsurePrefix(); + await Dxvk.InstallDxvk(Settings.Prefix, dxvkDirectory, DxvkSettings).ConfigureAwait(false); + + IsToolReady = true; + } + + private async Task DownloadTool(DirectoryInfo tempPath) + { + using var client = new HttpClient(); + var tempFilePath = Path.Combine(tempPath.FullName, $"{Guid.NewGuid()}"); + + await File.WriteAllBytesAsync(tempFilePath, await client.GetByteArrayAsync(WINE_XIV_RELEASE_URL).ConfigureAwait(false)).ConfigureAwait(false); + + PlatformHelpers.Untar(tempFilePath, this.toolDirectory.FullName); + + Log.Information("Compatibility tool successfully extracted to {Path}", this.toolDirectory.FullName); + + File.Delete(tempFilePath); + } + + private void ResetPrefix() + { + Settings.Prefix.Refresh(); + + if (Settings.Prefix.Exists) + Settings.Prefix.Delete(true); + + Settings.Prefix.Create(); + EnsurePrefix(); + } + + public void EnsurePrefix() + { + RunInPrefix("cmd /c dir %userprofile%/Documents > nul").WaitForExit(); + } + + public Process RunInPrefix(string command, string workingDirectory = "", IDictionary environment = null, bool redirectOutput = false, bool writeLog = false, bool wineD3D = false) + { + var psi = new ProcessStartInfo(Wine64Path); + psi.Arguments = command; + + Log.Verbose("Running in prefix: {FileName} {Arguments}", psi.FileName, command); + return RunInPrefix(psi, workingDirectory, environment, redirectOutput, writeLog, wineD3D); + } + + public Process RunInPrefix(string[] args, string workingDirectory = "", IDictionary environment = null, bool redirectOutput = false, bool writeLog = false, bool wineD3D = false) + { + var psi = new ProcessStartInfo(Wine64Path); + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + Log.Verbose("Running in prefix: {FileName} {Arguments}", psi.FileName, psi.ArgumentList.Aggregate(string.Empty, (a, b) => a + " " + b)); + return RunInPrefix(psi, workingDirectory, environment, redirectOutput, writeLog, wineD3D); + } + + private void MergeDictionaries(StringDictionary a, IDictionary b) + { + if (b is null) + return; + + foreach (var keyValuePair in b) + { + if (a.ContainsKey(keyValuePair.Key)) + a[keyValuePair.Key] = keyValuePair.Value; + else + a.Add(keyValuePair.Key, keyValuePair.Value); + } + } + + private Process RunInPrefix(ProcessStartInfo psi, string workingDirectory, IDictionary environment, bool redirectOutput, bool writeLog, bool wineD3D) + { + psi.RedirectStandardOutput = redirectOutput; + psi.RedirectStandardError = writeLog; + psi.UseShellExecute = false; + psi.WorkingDirectory = workingDirectory; + + var wineEnviromentVariables = new Dictionary(); + wineEnviromentVariables.Add("WINEPREFIX", Settings.Prefix.FullName); + wineEnviromentVariables.Add("WINEDLLOVERRIDES", $"msquic=,mscoree=n,b;d3d9,d3d11,d3d10core,dxgi={(DxvkSettings.Enabled && !wineD3D ? "n" : "b")}"); + + if (!string.IsNullOrEmpty(Settings.DebugVars)) + { + wineEnviromentVariables.Add("WINEDEBUG", Settings.DebugVars); + } + + wineEnviromentVariables.Add("XL_WINEONLINUX", "true"); + string ldPreload = Environment.GetEnvironmentVariable("LD_PRELOAD") ?? ""; + + if (gamemodeOn && !ldPreload.Contains("libgamemodeauto.so.0")) + { + ldPreload = ldPreload.Equals("") ? "libgamemodeauto.so.0" : ldPreload + ":libgamemodeauto.so.0"; + } + + foreach (KeyValuePair dxvkVar in DxvkSettings.DxvkVars) + wineEnviromentVariables.Add(dxvkVar.Key, dxvkVar.Value); + + wineEnviromentVariables.Add("WINEESYNC", Settings.EsyncOn); + wineEnviromentVariables.Add("WINEFSYNC", Settings.FsyncOn); + + wineEnviromentVariables.Add("LD_PRELOAD", ldPreload); + + MergeDictionaries(psi.EnvironmentVariables, wineEnviromentVariables); + MergeDictionaries(psi.EnvironmentVariables, environment); + +#if FLATPAK_NOTRIGHTNOW + psi.FileName = "flatpak-spawn"; + + psi.ArgumentList.Insert(0, "--host"); + psi.ArgumentList.Insert(1, Wine64Path); + + foreach (KeyValuePair envVar in wineEnviromentVariables) + { + psi.ArgumentList.Insert(1, $"--env={envVar.Key}={envVar.Value}"); + } + + if (environment != null) + { + foreach (KeyValuePair envVar in environment) + { + psi.ArgumentList.Insert(1, $"--env=\"{envVar.Key}\"=\"{envVar.Value}\""); + } + } +#endif + + Process helperProcess = new(); + helperProcess.StartInfo = psi; + helperProcess.ErrorDataReceived += new DataReceivedEventHandler((_, errLine) => + { + if (String.IsNullOrEmpty(errLine.Data)) + return; + + try + { + logWriter.WriteLine(errLine.Data); + Console.Error.WriteLine(errLine.Data); + } + catch (Exception ex) when (ex is ArgumentOutOfRangeException || + ex is OverflowException || + ex is IndexOutOfRangeException) + { + // very long wine log lines get chopped off after a (seemingly) arbitrary limit resulting in strings that are not null terminated + //logWriter.WriteLine("Error writing Wine log line:"); + //logWriter.WriteLine(ex.Message); + } + }); + + helperProcess.Start(); + if (writeLog) + helperProcess.BeginErrorReadLine(); + + return helperProcess; + } + + public Int32[] GetProcessIds(string executableName) + { + var wineDbg = RunInPrefix("winedbg --command \"info proc\"", redirectOutput: true); + var output = wineDbg.StandardOutput.ReadToEnd(); + var matchingLines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries).Where(l => l.Contains(executableName)); + return matchingLines.Select(l => int.Parse(l.Substring(1, 8), System.Globalization.NumberStyles.HexNumber)).ToArray(); + } + + public Int32 GetProcessId(string executableName) + { + return GetProcessIds(executableName).FirstOrDefault(); + } + + public Int32 GetUnixProcessId(Int32 winePid) + { + var wineDbg = RunInPrefix("winedbg --command \"info procmap\"", redirectOutput: true); + var output = wineDbg.StandardOutput.ReadToEnd(); + if (output.Contains("syntax error\n")) + return 0; + var matchingLines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries).Skip(1).Where( + l => int.Parse(l.Substring(1, 8), System.Globalization.NumberStyles.HexNumber) == winePid); + var unixPids = matchingLines.Select(l => int.Parse(l.Substring(10, 8), System.Globalization.NumberStyles.HexNumber)).ToArray(); + return unixPids.FirstOrDefault(); + } + + public string UnixToWinePath(string unixPath) + { + var launchArguments = new string[] { "winepath", "--windows", unixPath }; + var winePath = RunInPrefix(launchArguments, redirectOutput: true); + var output = winePath.StandardOutput.ReadToEnd(); + return output.Split('\n', StringSplitOptions.RemoveEmptyEntries).LastOrDefault(); + } + + public void AddRegistryKey(string key, string value, string data) + { + var args = new string[] { "reg", "add", key, "/v", value, "/d", data, "/f" }; + var wineProcess = RunInPrefix(args); + wineProcess.WaitForExit(); + } + + public void Kill() + { + var psi = new ProcessStartInfo(WineServerPath) + { + Arguments = "-k" + }; + psi.EnvironmentVariables.Add("WINEPREFIX", Settings.Prefix.FullName); + + Process.Start(psi); + } +} diff --git a/src/XIVLauncher2.Common.Unix/Compatibility/Dxvk.cs b/src/XIVLauncher2.Common.Unix/Compatibility/Dxvk.cs new file mode 100644 index 0000000..4fea8ea --- /dev/null +++ b/src/XIVLauncher2.Common.Unix/Compatibility/Dxvk.cs @@ -0,0 +1,79 @@ +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Serilog; +using XIVLauncher2.Common.Util; + +namespace XIVLauncher2.Common.Unix.Compatibility; + +public static class Dxvk +{ + public static async Task InstallDxvk(DirectoryInfo prefix, DirectoryInfo installDirectory, DxvkSettings dxvkSettings) + { + var dxvkPath = Path.Combine(installDirectory.FullName, dxvkSettings.FolderName, "x64"); + + if (!Directory.Exists(dxvkPath)) + { + Log.Information("DXVK does not exist, downloading"); + await DownloadDxvk(installDirectory, dxvkSettings.DownloadURL).ConfigureAwait(false); + } + + var system32 = Path.Combine(prefix.FullName, "drive_c", "windows", "system32"); + var files = Directory.GetFiles(dxvkPath); + + foreach (string fileName in files) + { + File.Copy(fileName, Path.Combine(system32, Path.GetFileName(fileName)), true); + } + } + + private static async Task DownloadDxvk(DirectoryInfo installDirectory, string downloadURL) + { + using var client = new HttpClient(); + var tempPath = Path.GetTempFileName(); + + File.WriteAllBytes(tempPath, await client.GetByteArrayAsync(downloadURL)); + PlatformHelpers.Untar(tempPath, installDirectory.FullName); + + File.Delete(tempPath); + } + + public enum DxvkHudType + { + [SettingsDescription("None", "Show nothing")] + None, + + [SettingsDescription("FPS", "Only show FPS")] + Fps, + + [SettingsDescription("DXVK Hud Custom", "Use a custom DXVK_HUD string")] + Custom, + + [SettingsDescription("Full", "Show everything")] + Full, + + [SettingsDescription("MangoHud Default", "Uses no config file.")] + MangoHud, + + [SettingsDescription("MangoHud Custom", "Specify a custom config file")] + MangoHudCustom, + + [SettingsDescription("MangoHud Full", "Show (almost) everything")] + MangoHudFull, + } + + public enum DxvkVersion + { + [SettingsDescription("1.10.1", "The version of DXVK used with XIVLauncher.Core 1.0.2. Safe to use.")] + v1_10_1, + + [SettingsDescription("1.10.2", "Older version of 1.10 branch of DXVK. Safe to use.")] + v1_10_2, + + [SettingsDescription("1.10.3 (default)", "Current version of 1.10 branch of DXVK.")] + v1_10_3, + + [SettingsDescription("2.0 (might break Dalamud, GShade)", "Newest version of DXVK. May be faster, but not stable yet.")] + v2_0, + } +} diff --git a/src/XIVLauncher2.Common.Unix/Compatibility/DxvkSettings.cs b/src/XIVLauncher2.Common.Unix/Compatibility/DxvkSettings.cs new file mode 100644 index 0000000..5c907dc --- /dev/null +++ b/src/XIVLauncher2.Common.Unix/Compatibility/DxvkSettings.cs @@ -0,0 +1,131 @@ +#nullable enable +using System; +using System.IO; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace XIVLauncher2.Common.Unix.Compatibility; + +public class DxvkSettings +{ + public bool Enabled { get; } + + public string DownloadURL { get; } + + public string FolderName { get; } + + public Dictionary DxvkVars { get; } + + public Dxvk.DxvkHudType DxvkHud { get; } + + public Dxvk.DxvkVersion DxvkVersion { get; } + + private const string ALLOWED_CHARS = "^[0-9a-zA-Z,=.]+$"; + + private const string ALLOWED_WORDS = "^(?:devinfo|fps|frametimes|submissions|drawcalls|pipelines|descriptors|memory|gpuload|version|api|cs|compiler|samplers|scale=(?:[0-9])*(?:.(?:[0-9])+)?)$"; + + public DxvkSettings(Dxvk.DxvkHudType hud, DirectoryInfo corePath, Dxvk.DxvkVersion version, bool enabled = true, string? dxvkHudCustom = null, FileInfo? mangoHudConfig = null, bool async = true, + int maxFrameRate = 0) + { + Enabled = enabled; + DxvkHud = hud; + var dxvkConfigPath = new DirectoryInfo(Path.Combine(corePath.FullName, "compatibilitytool", "dxvk")); + if (!dxvkConfigPath.Exists) + dxvkConfigPath.Create(); + DxvkVars = new Dictionary + { + { "DXVK_LOG_PATH", Path.Combine(corePath.FullName, "logs") }, + { "DXVK_CONFIG_FILE", Path.Combine(dxvkConfigPath.FullName, "dxvk.conf") }, + { "DXVK_ASYNC", async ? "1" : "0" }, + { "DXVK_FRAME_RATE", (maxFrameRate).ToString() } + }; + DxvkVersion = version; + var release = DxvkVersion switch + { + Dxvk.DxvkVersion.v1_10_1 => "1.10.1", + Dxvk.DxvkVersion.v1_10_2 => "1.10.2", + Dxvk.DxvkVersion.v1_10_3 => "1.10.3", + Dxvk.DxvkVersion.v2_0 => "2.0", + _ => throw new ArgumentOutOfRangeException(), + }; + DownloadURL = $"https://github.com/Sporif/dxvk-async/releases/download/{release}/dxvk-async-{release}.tar.gz"; + FolderName = $"dxvk-async-{release}"; + DirectoryInfo dxvkCachePath = new DirectoryInfo(Path.Combine(dxvkConfigPath.FullName, "cache")); + if (!dxvkCachePath.Exists) dxvkCachePath.Create(); + this.DxvkVars.Add("DXVK_STATE_CACHE_PATH", Path.Combine(dxvkCachePath.FullName, release + (async ? "-async" : ""))); + + switch(this.DxvkHud) + { + case Dxvk.DxvkHudType.Fps: + DxvkVars.Add("DXVK_HUD","fps"); + DxvkVars.Add("MANGOHUD","0"); + break; + + case Dxvk.DxvkHudType.Custom: + if (!CheckDxvkHudString(dxvkHudCustom)) + dxvkHudCustom = "fps,frametimes,gpuload,version"; + DxvkVars.Add("DXVK_HUD", dxvkHudCustom!); + DxvkVars.Add("MANGOHUD","0"); + break; + + case Dxvk.DxvkHudType.Full: + DxvkVars.Add("DXVK_HUD","full"); + DxvkVars.Add("MANGOHUD","0"); + break; + + case Dxvk.DxvkHudType.MangoHud: + DxvkVars.Add("DXVK_HUD","0"); + DxvkVars.Add("MANGOHUD","1"); + DxvkVars.Add("MANGOHUD_CONFIG", ""); + break; + + case Dxvk.DxvkHudType.MangoHudCustom: + DxvkVars.Add("DXVK_HUD","0"); + DxvkVars.Add("MANGOHUD","1"); + + if (mangoHudConfig is null) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var conf1 = Path.Combine(corePath.FullName, "MangoHud.conf"); + var conf2 = Path.Combine(home, ".config", "MangoHud", "wine-ffxiv_dx11.conf"); + var conf3 = Path.Combine(home, ".config", "MangoHud", "MangoHud.conf"); + if (File.Exists(conf1)) + mangoHudConfig = new FileInfo(conf1); + else if (File.Exists(conf2)) + mangoHudConfig = new FileInfo(conf2); + else if (File.Exists(conf3)) + mangoHudConfig = new FileInfo(conf3); + } + + if (mangoHudConfig != null && mangoHudConfig.Exists) + DxvkVars.Add("MANGOHUD_CONFIGFILE", mangoHudConfig.FullName); + else + DxvkVars.Add("MANGOHUD_CONFIG", ""); + break; + + case Dxvk.DxvkHudType.MangoHudFull: + DxvkVars.Add("DXVK_HUD","0"); + DxvkVars.Add("MANGOHUD","1"); + DxvkVars.Add("MANGOHUD_CONFIG","full"); + break; + + case Dxvk.DxvkHudType.None: + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } + + public static bool CheckDxvkHudString(string? customHud) + { + if (string.IsNullOrWhiteSpace(customHud)) return false; + if (customHud == "1") return true; + if (!Regex.IsMatch(customHud,ALLOWED_CHARS)) return false; + + string[] hudvars = customHud.Split(","); + + return hudvars.All(hudvar => Regex.IsMatch(hudvar, ALLOWED_WORDS)); + } +} diff --git a/src/XIVLauncher2.Common.Unix/Compatibility/GameFixes/GameFix.cs b/src/XIVLauncher2.Common.Unix/Compatibility/GameFixes/GameFix.cs new file mode 100644 index 0000000..a3b40e2 --- /dev/null +++ b/src/XIVLauncher2.Common.Unix/Compatibility/GameFixes/GameFix.cs @@ -0,0 +1,28 @@ +using System.IO; + +namespace XIVLauncher2.Common.Unix.Compatibility.GameFixes; + +public abstract class GameFix +{ + public GameFix(DirectoryInfo gameDirectory, DirectoryInfo configDirectory, DirectoryInfo winePrefixDirectory, DirectoryInfo tempDirectory) + { + GameDir = gameDirectory; + ConfigDir = configDirectory; + WinePrefixDir = winePrefixDirectory; + TempDir = tempDirectory; + } + + public abstract string LoadingTitle { get; } + + public GameFixApply.UpdateProgressDelegate UpdateProgress; + + public DirectoryInfo WinePrefixDir { get; private set; } + + public DirectoryInfo ConfigDir { get; private set; } + + public DirectoryInfo GameDir { get; private set; } + + public DirectoryInfo TempDir { get; private set; } + + public abstract void Apply(); +} \ No newline at end of file diff --git a/src/XIVLauncher2.Common.Unix/Compatibility/GameFixes/GameFixApply.cs b/src/XIVLauncher2.Common.Unix/Compatibility/GameFixes/GameFixApply.cs new file mode 100644 index 0000000..4f33a9c --- /dev/null +++ b/src/XIVLauncher2.Common.Unix/Compatibility/GameFixes/GameFixApply.cs @@ -0,0 +1,33 @@ +using System.IO; +using XIVLauncher2.Common.Unix.Compatibility.GameFixes.Implementations; + +namespace XIVLauncher2.Common.Unix.Compatibility.GameFixes; + +public class GameFixApply +{ + private readonly GameFix[] fixes; + + public delegate void UpdateProgressDelegate(string loadingText, bool hasProgress, float progress); + + public event UpdateProgressDelegate UpdateProgress; + + public GameFixApply(DirectoryInfo gameDirectory, DirectoryInfo configDirectory, DirectoryInfo winePrefixDirectory, DirectoryInfo tempDirectory) + { + this.fixes = new GameFix[] + { + new MacVideoFix(gameDirectory, configDirectory, winePrefixDirectory, tempDirectory), + }; + } + + public void Run() + { + foreach (GameFix fix in this.fixes) + { + this.UpdateProgress?.Invoke(fix.LoadingTitle, false, 0f); + + fix.UpdateProgress += this.UpdateProgress; + fix.Apply(); + fix.UpdateProgress -= this.UpdateProgress; + } + } +} \ No newline at end of file diff --git a/src/XIVLauncher2.Common.Unix/Compatibility/GameFixes/Implementations/MacVideoFix.cs b/src/XIVLauncher2.Common.Unix/Compatibility/GameFixes/Implementations/MacVideoFix.cs new file mode 100644 index 0000000..d05881d --- /dev/null +++ b/src/XIVLauncher2.Common.Unix/Compatibility/GameFixes/Implementations/MacVideoFix.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using XIVLauncher2.Common.Util; + +namespace XIVLauncher2.Common.Unix.Compatibility.GameFixes.Implementations; + +public class MacVideoFix : GameFix +{ + private const string MAC_ZIP_URL = "https://mac-dl.ffxiv.com/cw/finalfantasyxiv-1.0.8.zip"; + + public MacVideoFix(DirectoryInfo gameDirectory, DirectoryInfo configDirectory, DirectoryInfo winePrefixDirectory, DirectoryInfo tempDirectory) + : base(gameDirectory, configDirectory, winePrefixDirectory, tempDirectory) + { + } + + public override string LoadingTitle => "Preparing FMV cutscenes..."; + + public override void Apply() + { + var outputDirectory = new DirectoryInfo(Path.Combine(GameDir.FullName, "game", "movie", "ffxiv")); + var movieFileNames = new [] { "00000.bk2", "00001.bk2", "00002.bk2", "00003.bk2" }; + var movieFiles = movieFileNames.Select(movie => new FileInfo(Path.Combine(outputDirectory.FullName, movie))); + + if (movieFiles.All((movieFile) => movieFile.Exists)) + return; + + var zipFilePath = Path.Combine(TempDir.FullName, $"{Guid.NewGuid()}.zip"); + using var client = new HttpClientDownloadWithProgress(MAC_ZIP_URL, zipFilePath); + client.ProgressChanged += (size, downloaded, percentage) => + { + if (percentage != null && size != null) + { + this.UpdateProgress?.Invoke($"{LoadingTitle} ({ApiHelpers.BytesToString(downloaded)}/{ApiHelpers.BytesToString(size.Value)})", true, (float)(percentage.Value / 100f)); + } + }; + + client.Download().GetAwaiter().GetResult(); + + var zipMovieFileNames = movieFileNames.Select(movie => Path.Combine("game", "movie", "ffxiv", movie)); + + using (ZipArchive archive = ZipFile.OpenRead(zipFilePath)) + { + foreach (ZipArchiveEntry entry in archive.Entries) + { + if (zipMovieFileNames.Any((fileName) => entry.FullName.EndsWith(fileName, StringComparison.OrdinalIgnoreCase))) + { + string destinationPath = Path.Combine(outputDirectory.FullName, entry.Name); + if (!File.Exists(destinationPath)) + entry.ExtractToFile(destinationPath); + } + } + } + + File.Delete(zipFilePath); + } +} diff --git a/src/XIVLauncher2.Common.Unix/Compatibility/WineSettings.cs b/src/XIVLauncher2.Common.Unix/Compatibility/WineSettings.cs new file mode 100644 index 0000000..a1ee84d --- /dev/null +++ b/src/XIVLauncher2.Common.Unix/Compatibility/WineSettings.cs @@ -0,0 +1,37 @@ +using System.IO; + +namespace XIVLauncher2.Common.Unix.Compatibility; + +public enum WineStartupType +{ + [SettingsDescription("Managed by XIVLauncher", "The game installation and wine setup is managed by XIVLauncher - you can leave it up to us.")] + Managed, + + [SettingsDescription("Custom", "Point XIVLauncher to a custom location containing wine binaries to run the game with.")] + Custom, +} + +public class WineSettings +{ + public WineStartupType StartupType { get; private set; } + public string CustomBinPath { get; private set; } + + public string EsyncOn { get; private set; } + public string FsyncOn { get; private set; } + + public string DebugVars { get; private set; } + public FileInfo LogFile { get; private set; } + + public DirectoryInfo Prefix { get; private set; } + + public WineSettings(WineStartupType? startupType, string customBinPath, string debugVars, FileInfo logFile, DirectoryInfo prefix, bool? esyncOn, bool? fsyncOn) + { + this.StartupType = startupType ?? WineStartupType.Custom; + this.CustomBinPath = customBinPath; + this.EsyncOn = (esyncOn ?? false) ? "1" : "0"; + this.FsyncOn = (fsyncOn ?? false) ? "1" : "0"; + this.DebugVars = debugVars; + this.LogFile = logFile; + this.Prefix = prefix; + } +} \ No newline at end of file diff --git a/src/XIVLauncher2.Common.Unix/UnixDalamudCompatibilityCheck.cs b/src/XIVLauncher2.Common.Unix/UnixDalamudCompatibilityCheck.cs new file mode 100644 index 0000000..89cd07e --- /dev/null +++ b/src/XIVLauncher2.Common.Unix/UnixDalamudCompatibilityCheck.cs @@ -0,0 +1,33 @@ +using System.Runtime.InteropServices; +using XIVLauncher2.Common.PlatformAbstractions; + +namespace XIVLauncher2.Common.Unix; + +public class UnixDalamudCompatibilityCheck : IDalamudCompatibilityCheck +{ + public void EnsureCompatibility() + { + //Dalamud will work with wines built-in vcrun, so no need to check that + EnsureArchitecture(); + } + + private static void EnsureArchitecture() + { + var arch = RuntimeInformation.ProcessArchitecture; + + switch (arch) + { + case Architecture.X86: + throw new IDalamudCompatibilityCheck.ArchitectureNotSupportedException("Dalamud is not supported on x86 architecture."); + + case Architecture.X64: + break; + + case Architecture.Arm: + throw new IDalamudCompatibilityCheck.ArchitectureNotSupportedException("Dalamud is not supported on ARM32."); + + case Architecture.Arm64: + throw new IDalamudCompatibilityCheck.ArchitectureNotSupportedException("x64 emulation was not detected. Please make sure to run XIVLauncher with x64 emulation."); + } + } +} diff --git a/src/XIVLauncher2.Common.Unix/UnixDalamudRunner.cs b/src/XIVLauncher2.Common.Unix/UnixDalamudRunner.cs new file mode 100644 index 0000000..4ed3b0f --- /dev/null +++ b/src/XIVLauncher2.Common.Unix/UnixDalamudRunner.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json; +using Serilog; +using XIVLauncher2.Common.Dalamud; +using XIVLauncher2.Common.PlatformAbstractions; +using XIVLauncher2.Common.Unix.Compatibility; + +namespace XIVLauncher2.Common.Unix; + +public class UnixDalamudRunner : IDalamudRunner +{ + private readonly CompatibilityTools compatibility; + private readonly DirectoryInfo dotnetRuntime; + + public UnixDalamudRunner(CompatibilityTools compatibility, DirectoryInfo dotnetRuntime) + { + this.compatibility = compatibility; + this.dotnetRuntime = dotnetRuntime; + } + + public Process? Run(FileInfo runner, bool fakeLogin, bool noPlugins, bool noThirdPlugins, FileInfo gameExe, string gameArgs, IDictionary environment, DalamudLoadMethod loadMethod, DalamudStartInfo startInfo) + { + var gameExePath = ""; + var dotnetRuntimePath = ""; + + Parallel.Invoke( + () => { gameExePath = compatibility.UnixToWinePath(gameExe.FullName); }, + () => { dotnetRuntimePath = compatibility.UnixToWinePath(dotnetRuntime.FullName); }, + () => { startInfo.WorkingDirectory = compatibility.UnixToWinePath(startInfo.WorkingDirectory); }, + () => { startInfo.ConfigurationPath = compatibility.UnixToWinePath(startInfo.ConfigurationPath); }, + () => { startInfo.PluginDirectory = compatibility.UnixToWinePath(startInfo.PluginDirectory); }, + () => { startInfo.DefaultPluginDirectory = compatibility.UnixToWinePath(startInfo.DefaultPluginDirectory); }, + () => { startInfo.AssetDirectory = compatibility.UnixToWinePath(startInfo.AssetDirectory); } + ); + + var prevDalamudRuntime = Environment.GetEnvironmentVariable("DALAMUD_RUNTIME"); + if (string.IsNullOrWhiteSpace(prevDalamudRuntime)) + environment.Add("DALAMUD_RUNTIME", dotnetRuntimePath); + + var launchArguments = new List + { + $"\"{runner.FullName}\"", + DalamudInjectorArgs.Launch, + DalamudInjectorArgs.Mode(loadMethod == DalamudLoadMethod.EntryPoint ? "entrypoint" : "inject"), + DalamudInjectorArgs.Game(gameExePath), + DalamudInjectorArgs.WorkingDirectory(startInfo.WorkingDirectory), + DalamudInjectorArgs.ConfigurationPath(startInfo.ConfigurationPath), + DalamudInjectorArgs.PluginDirectory(startInfo.PluginDirectory), + DalamudInjectorArgs.PluginDevDirectory(startInfo.DefaultPluginDirectory), + DalamudInjectorArgs.AssetDirectory(startInfo.AssetDirectory), + DalamudInjectorArgs.ClientLanguage((int)startInfo.Language), + DalamudInjectorArgs.DelayInitialize(startInfo.DelayInitializeMs), + DalamudInjectorArgs.TSPackB64(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(startInfo.TroubleshootingPackData))), + }; + + if (loadMethod == DalamudLoadMethod.ACLonly) + launchArguments.Add(DalamudInjectorArgs.WithoutDalamud); + + if (fakeLogin) + launchArguments.Add(DalamudInjectorArgs.FakeArguments); + + if (noPlugins) + launchArguments.Add(DalamudInjectorArgs.NoPlugin); + + if (noThirdPlugins) + launchArguments.Add(DalamudInjectorArgs.NoThirdParty); + + launchArguments.Add("--"); + launchArguments.Add(gameArgs); + + var dalamudProcess = compatibility.RunInPrefix(string.Join(" ", launchArguments), environment: environment, redirectOutput: true, writeLog: true); + var output = dalamudProcess.StandardOutput.ReadLine(); + + if (output == null) + throw new DalamudRunnerException("An internal Dalamud error has occured"); + + Console.WriteLine(output); + + new Thread(() => + { + while (!dalamudProcess.StandardOutput.EndOfStream) + { + var output = dalamudProcess.StandardOutput.ReadLine(); + if (output != null) + Console.WriteLine(output); + } + + }).Start(); + + try + { + var dalamudConsoleOutput = JsonSerializer.Deserialize(output); + var unixPid = compatibility.GetUnixProcessId(dalamudConsoleOutput.Pid); + + if (unixPid == 0) + { + Log.Error("Could not retrive Unix process ID, this feature currently requires a patched wine version"); + return null; + } + + var gameProcess = Process.GetProcessById(unixPid); + Log.Verbose($"Got game process handle {gameProcess.Handle} with Unix pid {gameProcess.Id} and Wine pid {dalamudConsoleOutput.Pid}"); + return gameProcess; + } + catch (JsonException ex) + { + Log.Error(ex, $"Couldn't parse Dalamud output: {output}"); + return null; + } + } +} diff --git a/src/XIVLauncher2.Common.Unix/UnixGameRunner.cs b/src/XIVLauncher2.Common.Unix/UnixGameRunner.cs new file mode 100644 index 0000000..5ac2e46 --- /dev/null +++ b/src/XIVLauncher2.Common.Unix/UnixGameRunner.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using XIVLauncher2.Common.Dalamud; +using XIVLauncher2.Common.PlatformAbstractions; +using XIVLauncher2.Common.Unix.Compatibility; + +namespace XIVLauncher2.Common.Unix; + +public class UnixGameRunner : IGameRunner +{ + private readonly CompatibilityTools compatibility; + private readonly DalamudLauncher dalamudLauncher; + private readonly bool dalamudOk; + + public UnixGameRunner(CompatibilityTools compatibility, DalamudLauncher dalamudLauncher, bool dalamudOk) + { + this.compatibility = compatibility; + this.dalamudLauncher = dalamudLauncher; + this.dalamudOk = dalamudOk; + } + + public Process? Start(string path, string workingDirectory, string arguments, IDictionary environment, DpiAwareness dpiAwareness) + { + if (dalamudOk) + { + return this.dalamudLauncher.Run(new FileInfo(path), arguments, environment); + } + else + { + return compatibility.RunInPrefix($"\"{path}\" {arguments}", workingDirectory, environment, writeLog: true); + } + } +} diff --git a/src/XIVLauncher2.Common.Unix/UnixSteam.cs b/src/XIVLauncher2.Common.Unix/UnixSteam.cs new file mode 100644 index 0000000..5d24d9d --- /dev/null +++ b/src/XIVLauncher2.Common.Unix/UnixSteam.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading.Tasks; +using Steamworks; +using XIVLauncher2.Common.PlatformAbstractions; + +namespace XIVLauncher2.Common.Unix +{ + public class UnixSteam : ISteam + { + public UnixSteam() + { + SteamUtils.OnGamepadTextInputDismissed += b => OnGamepadTextInputDismissed?.Invoke(b); + } + + public void Initialize(uint appId) + { + // workaround because SetEnvironmentVariable doesn't actually touch the process environment on unix + [System.Runtime.InteropServices.DllImport("c")] + static extern int setenv(string name, string value, int overwrite); + + setenv("SteamAppId", appId.ToString(), 1); + setenv("SteamGameId", appId.ToString(), 1); + + SteamClient.Init(appId); + } + + public bool IsValid => SteamClient.IsValid; + + public bool BLoggedOn => SteamClient.IsLoggedOn; + + public bool BOverlayNeedsPresent => SteamUtils.DoesOverlayNeedPresent; + + public void Shutdown() + { + SteamClient.Shutdown(); + } + + public async Task GetAuthSessionTicketAsync() + { + var ticket = await SteamUser.GetAuthSessionTicketAsync().ConfigureAwait(true); + return ticket?.Data; + } + + public bool IsAppInstalled(uint appId) + { + return SteamApps.IsAppInstalled(appId); + } + + public string GetAppInstallDir(uint appId) + { + return SteamApps.AppInstallDir(appId); + } + + public bool ShowGamepadTextInput(bool password, bool multiline, string description, int maxChars, string existingText = "") + { + return SteamUtils.ShowGamepadTextInput(password ? GamepadTextInputMode.Password : GamepadTextInputMode.Normal, multiline ? GamepadTextInputLineMode.MultipleLines : GamepadTextInputLineMode.SingleLine, description, maxChars, existingText); + } + + public string GetEnteredGamepadText() + { + return SteamUtils.GetEnteredGamepadText(); + } + + public bool ShowFloatingGamepadTextInput(ISteam.EFloatingGamepadTextInputMode mode, int x, int y, int width, int height) + { + // Facepunch.Steamworks doesn't have this... + return false; + } + + public bool IsRunningOnSteamDeck() => SteamUtils.IsRunningOnSteamDeck; + + public uint GetServerRealTime() => (uint)((DateTimeOffset)SteamUtils.SteamServerTime).ToUnixTimeSeconds(); + + public void ActivateGameOverlayToWebPage(string url, bool modal = false) + { + SteamFriends.OpenWebOverlay(url, modal); + } + + public event Action OnGamepadTextInputDismissed; + } +} diff --git a/src/XIVLauncher2.Common.Unix/XIVLauncher2.Common.Unix.csproj b/src/XIVLauncher2.Common.Unix/XIVLauncher2.Common.Unix.csproj new file mode 100644 index 0000000..f2da5f2 --- /dev/null +++ b/src/XIVLauncher2.Common.Unix/XIVLauncher2.Common.Unix.csproj @@ -0,0 +1,50 @@ + + + XIVLauncher2.Common.Unix + XIVLauncher2.Common.Unix + Shared XIVLauncher platform-specific implementations for Unix-like systems. + 1.0.0 + disable + + + + Library + net7.0 + latest + true + true + + + + $(DefineConstants);$(ExtraDefineConstants) + + + + + + + + + $(MSBuildProjectDirectory)\ + $(AppOutputBase)=C:\goatsoft\xl\XIVLauncher.Common.Unix\ + + + + + + PreserveNewest + + + + + + + PreserveNewest + + + + + + + + diff --git a/src/XIVLauncher2.Common.Unix/libsteam_api64.dylib b/src/XIVLauncher2.Common.Unix/libsteam_api64.dylib new file mode 100644 index 0000000..8d3c5ee Binary files /dev/null and b/src/XIVLauncher2.Common.Unix/libsteam_api64.dylib differ diff --git a/src/XIVLauncher2.Common.Unix/libsteam_api64.so b/src/XIVLauncher2.Common.Unix/libsteam_api64.so new file mode 100644 index 0000000..8bf6762 Binary files /dev/null and b/src/XIVLauncher2.Common.Unix/libsteam_api64.so differ diff --git a/src/XIVLauncher2.Common.Windows/NativeAclFix.cs b/src/XIVLauncher2.Common.Windows/NativeAclFix.cs new file mode 100644 index 0000000..97cbe15 --- /dev/null +++ b/src/XIVLauncher2.Common.Windows/NativeAclFix.cs @@ -0,0 +1,537 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Linq; +using System.Diagnostics; +using System.Threading; +using Serilog; +using XIVLauncher2.Common; +using XIVLauncher2.Common.Game.Exceptions; + +// ReSharper disable InconsistentNaming + +namespace XIVLauncher.Common.Game +{ + public static class NativeAclFix + { + // Definitions taken from PInvoke.net (with some changes) + private static class PInvoke + { + #region Constants + public const UInt32 STANDARD_RIGHTS_ALL = 0x001F0000; + public const UInt32 SPECIFIC_RIGHTS_ALL = 0x0000FFFF; + public const UInt32 PROCESS_VM_WRITE = 0x0020; + + public const UInt32 GRANT_ACCESS = 1; + + public const UInt32 SECURITY_DESCRIPTOR_REVISION = 1; + + public const UInt32 CREATE_SUSPENDED = 0x00000004; + + public const UInt32 TOKEN_QUERY = 0x0008; + public const UInt32 TOKEN_ADJUST_PRIVILEGES = 0x0020; + + public const UInt32 PRIVILEGE_SET_ALL_NECESSARY = 1; + + public const UInt32 SE_PRIVILEGE_ENABLED = 0x00000002; + public const UInt32 SE_PRIVILEGE_REMOVED = 0x00000004; + + + public enum MULTIPLE_TRUSTEE_OPERATION + { + NO_MULTIPLE_TRUSTEE, + TRUSTEE_IS_IMPERSONATE + } + + public enum TRUSTEE_FORM + { + TRUSTEE_IS_SID, + TRUSTEE_IS_NAME, + TRUSTEE_BAD_FORM, + TRUSTEE_IS_OBJECTS_AND_SID, + TRUSTEE_IS_OBJECTS_AND_NAME + } + + public enum TRUSTEE_TYPE + { + TRUSTEE_IS_UNKNOWN, + TRUSTEE_IS_USER, + TRUSTEE_IS_GROUP, + TRUSTEE_IS_DOMAIN, + TRUSTEE_IS_ALIAS, + TRUSTEE_IS_WELL_KNOWN_GROUP, + TRUSTEE_IS_DELETED, + TRUSTEE_IS_INVALID, + TRUSTEE_IS_COMPUTER + } + + public enum SE_OBJECT_TYPE + { + SE_UNKNOWN_OBJECT_TYPE, + SE_FILE_OBJECT, + SE_SERVICE, + SE_PRINTER, + SE_REGISTRY_KEY, + SE_LMSHARE, + SE_KERNEL_OBJECT, + SE_WINDOW_OBJECT, + SE_DS_OBJECT, + SE_DS_OBJECT_ALL, + SE_PROVIDER_DEFINED_OBJECT, + SE_WMIGUID_OBJECT, + SE_REGISTRY_WOW64_32KEY + } + public enum SECURITY_INFORMATION + { + OWNER_SECURITY_INFORMATION = 1, + GROUP_SECURITY_INFORMATION = 2, + DACL_SECURITY_INFORMATION = 4, + SACL_SECURITY_INFORMATION = 8, + UNPROTECTED_SACL_SECURITY_INFORMATION = 0x10000000, + UNPROTECTED_DACL_SECURITY_INFORMATION = 0x20000000, + PROTECTED_SACL_SECURITY_INFORMATION = 0x40000000 + } + #endregion + + + #region Structures + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto, Pack = 0)] + public struct TRUSTEE : IDisposable + { + public IntPtr pMultipleTrustee; + public MULTIPLE_TRUSTEE_OPERATION MultipleTrusteeOperation; + public TRUSTEE_FORM TrusteeForm; + public TRUSTEE_TYPE TrusteeType; + private IntPtr ptstrName; + + void IDisposable.Dispose() + { + if (ptstrName != IntPtr.Zero) Marshal.Release(ptstrName); + } + + public string Name { get { return Marshal.PtrToStringAuto(ptstrName); } } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto, Pack = 0)] + public struct EXPLICIT_ACCESS + { + uint grfAccessPermissions; + uint grfAccessMode; + uint grfInheritance; + TRUSTEE Trustee; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SECURITY_DESCRIPTOR + { + public byte Revision; + public byte Sbz1; + public UInt16 Control; + public IntPtr Owner; + public IntPtr Group; + public IntPtr Sacl; + public IntPtr Dacl; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct STARTUPINFO + { + public Int32 cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public Int32 dwX; + public Int32 dwY; + public Int32 dwXSize; + public Int32 dwYSize; + public Int32 dwXCountChars; + public Int32 dwYCountChars; + public Int32 dwFillAttribute; + public Int32 dwFlags; + public Int16 wShowWindow; + public Int16 cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + public struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public UInt32 dwThreadId; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SECURITY_ATTRIBUTES + { + public int nLength; + public IntPtr lpSecurityDescriptor; + public bool bInheritHandle; + } + + [StructLayout(LayoutKind.Sequential)] + public struct LUID + { + public UInt32 LowPart; + public Int32 HighPart; + } + + [StructLayout(LayoutKind.Sequential)] + public struct PRIVILEGE_SET + { + public UInt32 PrivilegeCount; + public UInt32 Control; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 1)] + public LUID_AND_ATTRIBUTES[] Privilege; + } + + public struct LUID_AND_ATTRIBUTES + { + public LUID Luid; + public UInt32 Attributes; + } + + [StructLayout(LayoutKind.Sequential)] + public struct TOKEN_PRIVILEGES + { + public UInt32 PrivilegeCount; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 1)] + public LUID_AND_ATTRIBUTES[] Privileges; + } + #endregion + + + #region Methods + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)] + public static extern void BuildExplicitAccessWithName( + ref EXPLICIT_ACCESS pExplicitAccess, + string pTrusteeName, + uint AccessPermissions, + uint AccessMode, + uint Inheritance); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)] + public static extern int SetEntriesInAcl( + int cCountOfExplicitEntries, + ref EXPLICIT_ACCESS pListOfExplicitEntries, + IntPtr OldAcl, + out IntPtr NewAcl); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool InitializeSecurityDescriptor( + out SECURITY_DESCRIPTOR pSecurityDescriptor, + uint dwRevision); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool SetSecurityDescriptorDacl( + ref SECURITY_DESCRIPTOR pSecurityDescriptor, + bool bDaclPresent, + IntPtr pDacl, + bool bDaclDefaulted); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + public static extern bool CreateProcess( + string lpApplicationName, + string lpCommandLine, + ref SECURITY_ATTRIBUTES lpProcessAttributes, + IntPtr lpThreadAttributes, + bool bInheritHandles, + UInt32 dwCreationFlags, + IntPtr lpEnvironment, + string lpCurrentDirectory, + [In] ref STARTUPINFO lpStartupInfo, + out PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern uint ResumeThread(IntPtr hThread); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool OpenProcessToken( + IntPtr ProcessHandle, + UInt32 DesiredAccess, + out IntPtr TokenHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool LookupPrivilegeValue(string lpSystemName, string lpName, ref LUID lpLuid); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool PrivilegeCheck( + IntPtr ClientToken, + ref PRIVILEGE_SET RequiredPrivileges, + out bool pfResult); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool AdjustTokenPrivileges( + IntPtr TokenHandle, + bool DisableAllPrivileges, + ref TOKEN_PRIVILEGES NewState, + UInt32 BufferLengthInBytes, + IntPtr PreviousState, + UInt32 ReturnLengthInBytes); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern uint GetSecurityInfo( + IntPtr handle, + SE_OBJECT_TYPE ObjectType, + SECURITY_INFORMATION SecurityInfo, + IntPtr pSidOwner, + IntPtr pSidGroup, + out IntPtr pDacl, + IntPtr pSacl, + IntPtr pSecurityDescriptor); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern uint SetSecurityInfo( + IntPtr handle, + SE_OBJECT_TYPE ObjectType, + SECURITY_INFORMATION SecurityInfo, + IntPtr psidOwner, + IntPtr psidGroup, + IntPtr pDacl, + IntPtr pSacl); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr GetCurrentProcess(); + #endregion + } + + public static Process LaunchGame(string workingDir, string exePath, string arguments, IDictionary envVars, DpiAwareness dpiAwareness, Action beforeResume) + { + Process process = null; + + var userName = Environment.UserName; + + var pExplicitAccess = new PInvoke.EXPLICIT_ACCESS(); + PInvoke.BuildExplicitAccessWithName( + ref pExplicitAccess, + userName, + PInvoke.STANDARD_RIGHTS_ALL | PInvoke.SPECIFIC_RIGHTS_ALL & ~PInvoke.PROCESS_VM_WRITE, + PInvoke.GRANT_ACCESS, + 0); + + if (PInvoke.SetEntriesInAcl(1, ref pExplicitAccess, IntPtr.Zero, out var newAcl) != 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + var secDesc = new PInvoke.SECURITY_DESCRIPTOR(); + + if (!PInvoke.InitializeSecurityDescriptor(out secDesc, PInvoke.SECURITY_DESCRIPTOR_REVISION)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + if (!PInvoke.SetSecurityDescriptorDacl(ref secDesc, true, newAcl, false)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + var psecDesc = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(secDesc, psecDesc, true); + + var lpProcessInformation = new PInvoke.PROCESS_INFORMATION(); + var lpEnvironment = IntPtr.Zero; + + try + { + if (envVars.Count > 0) + { + string envstr = string.Join("\0", envVars.Select(entry => entry.Key + "=" + entry.Value)); + + lpEnvironment = Marshal.StringToHGlobalAnsi(envstr); + } + + var lpProcessAttributes = new PInvoke.SECURITY_ATTRIBUTES + { + nLength = Marshal.SizeOf(), + lpSecurityDescriptor = psecDesc, + bInheritHandle = false + }; + + var lpStartupInfo = new PInvoke.STARTUPINFO + { + cb = Marshal.SizeOf() + }; + + var compatLayerPrev = Environment.GetEnvironmentVariable("__COMPAT_LAYER"); + + var compat = "RunAsInvoker "; + compat += dpiAwareness switch + { + DpiAwareness.Aware => "HighDPIAware", + DpiAwareness.Unaware => "DPIUnaware", + _ => throw new ArgumentOutOfRangeException() + }; + Environment.SetEnvironmentVariable("__COMPAT_LAYER", compat); + + if (!PInvoke.CreateProcess( + null, + $"\"{exePath}\" {arguments}", + ref lpProcessAttributes, + IntPtr.Zero, + false, + PInvoke.CREATE_SUSPENDED, + IntPtr.Zero, + workingDir, + ref lpStartupInfo, + out lpProcessInformation)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + Environment.SetEnvironmentVariable("__COMPAT_LAYER", compatLayerPrev); + + DisableSeDebug(lpProcessInformation.hProcess); + + process = new ExistingProcess(lpProcessInformation.hProcess); + + beforeResume?.Invoke(process); + + PInvoke.ResumeThread(lpProcessInformation.hThread); + + // Ensure that the game main window is prepared + try + { + do + { + process.WaitForInputIdle(); + + Thread.Sleep(100); + } while (IntPtr.Zero == TryFindGameWindow(process)); + } + catch (InvalidOperationException) + { + throw new GameExitedException(); + } + + if (PInvoke.GetSecurityInfo( + PInvoke.GetCurrentProcess(), + PInvoke.SE_OBJECT_TYPE.SE_KERNEL_OBJECT, + PInvoke.SECURITY_INFORMATION.DACL_SECURITY_INFORMATION, + IntPtr.Zero, IntPtr.Zero, + out var pACL, + IntPtr.Zero, IntPtr.Zero) != 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + if (PInvoke.SetSecurityInfo( + lpProcessInformation.hProcess, + PInvoke.SE_OBJECT_TYPE.SE_KERNEL_OBJECT, + PInvoke.SECURITY_INFORMATION.DACL_SECURITY_INFORMATION | + PInvoke.SECURITY_INFORMATION.UNPROTECTED_DACL_SECURITY_INFORMATION, + IntPtr.Zero, IntPtr.Zero, pACL, IntPtr.Zero) != 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + catch (Exception ex) + { + Log.Error(ex, "[NativeAclFix] Uncaught error during initialization, trying to kill process"); + + try + { + process?.Kill(); + } + catch (Exception killEx) + { + Log.Error(killEx, "[NativeAclFix] Could not kill process"); + } + + throw; + } + finally + { + Marshal.FreeHGlobal(psecDesc); + + if (!IntPtr.Equals(lpEnvironment, IntPtr.Zero)) + { + Marshal.FreeHGlobal(lpEnvironment); + } + + PInvoke.CloseHandle(lpProcessInformation.hThread); + } + + return process; + } + + private static void DisableSeDebug(IntPtr ProcessHandle) + { + if (!PInvoke.OpenProcessToken(ProcessHandle, PInvoke.TOKEN_QUERY | PInvoke.TOKEN_ADJUST_PRIVILEGES, out var TokenHandle)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + var luidDebugPrivilege = new PInvoke.LUID(); + if (!PInvoke.LookupPrivilegeValue(null, "SeDebugPrivilege", ref luidDebugPrivilege)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + var RequiredPrivileges = new PInvoke.PRIVILEGE_SET + { + PrivilegeCount = 1, + Control = PInvoke.PRIVILEGE_SET_ALL_NECESSARY, + Privilege = new PInvoke.LUID_AND_ATTRIBUTES[1] + }; + + RequiredPrivileges.Privilege[0].Luid = luidDebugPrivilege; + RequiredPrivileges.Privilege[0].Attributes = PInvoke.SE_PRIVILEGE_ENABLED; + + if (!PInvoke.PrivilegeCheck(TokenHandle, ref RequiredPrivileges, out bool bResult)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + if (bResult) // SeDebugPrivilege is enabled; try disabling it + { + var TokenPrivileges = new PInvoke.TOKEN_PRIVILEGES + { + PrivilegeCount = 1, + Privileges = new PInvoke.LUID_AND_ATTRIBUTES[1] + }; + + TokenPrivileges.Privileges[0].Luid = luidDebugPrivilege; + TokenPrivileges.Privileges[0].Attributes = PInvoke.SE_PRIVILEGE_REMOVED; + + if (!PInvoke.AdjustTokenPrivileges(TokenHandle, false, ref TokenPrivileges, 0, IntPtr.Zero, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + + PInvoke.CloseHandle(TokenHandle); + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr FindWindowEx(IntPtr parentHandle, IntPtr hWndChildAfter, string className, IntPtr windowTitle); + [DllImport("user32.dll", SetLastError = true)] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + static extern bool IsWindowVisible(IntPtr hWnd); + + private static IntPtr TryFindGameWindow(Process process) + { + IntPtr hwnd = IntPtr.Zero; + while (IntPtr.Zero != (hwnd = FindWindowEx(IntPtr.Zero, hwnd, "FFXIVGAME", IntPtr.Zero))) + { + GetWindowThreadProcessId(hwnd, out uint pid); + + if (pid == process.Id && IsWindowVisible(hwnd)) + { + break; + } + } + return hwnd; + } + } +} diff --git a/src/XIVLauncher2.Common.Windows/WindowsDalamudCompatibilityCheck.cs b/src/XIVLauncher2.Common.Windows/WindowsDalamudCompatibilityCheck.cs new file mode 100644 index 0000000..aaf59bc --- /dev/null +++ b/src/XIVLauncher2.Common.Windows/WindowsDalamudCompatibilityCheck.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Microsoft.Win32; +using Serilog; +using XIVLauncher2.Common.PlatformAbstractions; + +namespace XIVLauncher2.Common.Windows; + +public class WindowsDalamudCompatibilityCheck : IDalamudCompatibilityCheck +{ + public void EnsureCompatibility() + { + if (!CheckVcRedists()) + throw new IDalamudCompatibilityCheck.NoRedistsException(); + + EnsureArchitecture(); + } + + private static void EnsureArchitecture() + { + var arch = RuntimeInformation.ProcessArchitecture; + + switch (arch) + { + case Architecture.X86: + throw new IDalamudCompatibilityCheck.ArchitectureNotSupportedException("Dalamud is not supported on x86 architecture."); + + case Architecture.X64: + break; + + case Architecture.Arm: + throw new IDalamudCompatibilityCheck.ArchitectureNotSupportedException("Dalamud is not supported on ARM32."); + + case Architecture.Arm64: + throw new IDalamudCompatibilityCheck.ArchitectureNotSupportedException("x64 emulation was not detected. Please make sure to run XIVLauncher with x64 emulation."); + } + } + + [DllImport("kernel32", SetLastError = true)] + private static extern IntPtr LoadLibrary(string lpFileName); + + private static bool CheckLibrary(string fileName) + { + if (LoadLibrary(fileName) != IntPtr.Zero) + { + Log.Debug("Found " + fileName); + return true; + } + else + { + Log.Error("Could not find " + fileName); + } + return false; + } + + private static bool CheckVcRedists() + { + // snipped from https://stackoverflow.com/questions/12206314/detect-if-visual-c-redistributable-for-visual-studio-2012-is-installed + // and https://github.com/bitbeans/RedistributableChecker + + var vc2022Paths = new List + { + @"SOFTWARE\Microsoft\DevDiv\VC\Servicing\14.0\RuntimeMinimum", + @"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\X64", + @"SOFTWARE\Classes\Installer\Dependencies\Microsoft.VS.VC_RuntimeMinimumVSU_amd64,v14", + @"SOFTWARE\Classes\Installer\Dependencies\VC,redist.x64,amd64,14.31,bundle", + @"SOFTWARE\Classes\Installer\Dependencies\VC,redist.x64,amd64,14.30,bundle", + @"SOFTWARE\Classes\Installer\Dependencies\VC,redist.x64,amd64,14.29,bundle", + @"SOFTWARE\Classes\Installer\Dependencies\VC,redist.x64,amd64,14.28,bundle", + // technically, this was introduced in VCrun2017 with 14.16 + // but we shouldn't go that far + // here's a legacy vcrun2017 check + @"Installer\Dependencies\,,amd64,14.0,bundle", + // here's one for vcrun2015 + @"SOFTWARE\Classes\Installer\Dependencies\{d992c12e-cab2-426f-bde3-fb8c53950b0d}" + }; + + var dllPaths = new List + { + "ucrtbase_clr0400", + "vcruntime140_clr0400", + "vcruntime140" + }; + + var passedRegistry = false; + var passedDllChecks = true; + + foreach (var path in vc2022Paths) + { + Log.Debug("Checking Registry key: " + path); + var vcregcheck = Registry.LocalMachine.OpenSubKey(path, false); + if (vcregcheck == null) continue; + + var vcVersioncheck = vcregcheck.GetValue("Version") ?? ""; + + if (((string)vcVersioncheck).StartsWith("14", StringComparison.Ordinal)) + { + passedRegistry = true; + Log.Debug("Passed Registry Check with: " + path); + break; + } + } + + foreach (var path in dllPaths) + { + Log.Debug("Checking for DLL: " + path); + passedDllChecks = passedDllChecks && CheckLibrary(path); + } + + // Display our findings + if (!passedRegistry) + { + Log.Error("Failed all registry checks to find any Visual C++ 2015-2022 Runtimes."); + } + + if (!passedDllChecks) + { + Log.Error("Missing DLL files required by Dalamud."); + } + + return (passedRegistry && passedDllChecks); + } +} diff --git a/src/XIVLauncher2.Common.Windows/WindowsDalamudRunner.cs b/src/XIVLauncher2.Common.Windows/WindowsDalamudRunner.cs new file mode 100644 index 0000000..ed0097c --- /dev/null +++ b/src/XIVLauncher2.Common.Windows/WindowsDalamudRunner.cs @@ -0,0 +1,201 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text.Json; +using Serilog; +using XIVLauncher2.Common.Dalamud; +using XIVLauncher2.Common.PlatformAbstractions; + +namespace XIVLauncher2.Common.Windows; + +public class WindowsDalamudRunner : IDalamudRunner +{ + public Process? Run(FileInfo runner, bool fakeLogin, bool noPlugins, bool noThirdPlugins, FileInfo gameExe, string gameArgs, IDictionary environment, DalamudLoadMethod loadMethod, DalamudStartInfo startInfo) + { + var inheritableCurrentProcess = GetInheritableCurrentProcessHandle(); + + var launchArguments = new List + { + DalamudInjectorArgs.Launch, + DalamudInjectorArgs.Mode(loadMethod == DalamudLoadMethod.EntryPoint ? "entrypoint" : "inject"), + DalamudInjectorArgs.HandleOwner((long)(inheritableCurrentProcess?.Handle ?? IntPtr.Zero)), + DalamudInjectorArgs.Game(gameExe.FullName), + DalamudInjectorArgs.WorkingDirectory(startInfo.WorkingDirectory), + DalamudInjectorArgs.ConfigurationPath(startInfo.ConfigurationPath), + DalamudInjectorArgs.PluginDirectory(startInfo.PluginDirectory), + DalamudInjectorArgs.PluginDevDirectory(startInfo.DefaultPluginDirectory), + DalamudInjectorArgs.AssetDirectory(startInfo.AssetDirectory), + DalamudInjectorArgs.ClientLanguage((int)startInfo.Language), + DalamudInjectorArgs.DelayInitialize(startInfo.DelayInitializeMs), + DalamudInjectorArgs.TSPackB64(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(startInfo.TroubleshootingPackData))), + }; + + if (loadMethod == DalamudLoadMethod.ACLonly) + launchArguments.Add(DalamudInjectorArgs.WithoutDalamud); + + if (fakeLogin) + launchArguments.Add(DalamudInjectorArgs.FakeArguments); + + if (noPlugins) + launchArguments.Add(DalamudInjectorArgs.NoPlugin); + + if (noThirdPlugins) + launchArguments.Add(DalamudInjectorArgs.NoThirdParty); + + launchArguments.Add("--"); + launchArguments.Add(gameArgs); + + var psi = new ProcessStartInfo(runner.FullName) { + Arguments = string.Join(" ", launchArguments), + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + foreach (var keyValuePair in environment) + { + if (psi.EnvironmentVariables.ContainsKey(keyValuePair.Key)) + psi.EnvironmentVariables[keyValuePair.Key] = keyValuePair.Value; + else + psi.EnvironmentVariables.Add(keyValuePair.Key, keyValuePair.Value); + } + + try + { + var dalamudProcess = Process.Start(psi); + var output = dalamudProcess.StandardOutput.ReadLine(); + + if (output == null) + throw new DalamudRunnerException("An internal Dalamud error has occured"); + + try + { + var dalamudConsoleOutput = JsonSerializer.Deserialize(output); + Process gameProcess; + + if (dalamudConsoleOutput.Handle == 0) + { + Log.Warning($"Dalamud returned NULL process handle, attempting to recover by creating a new one from pid {dalamudConsoleOutput.Pid}..."); + gameProcess = Process.GetProcessById(dalamudConsoleOutput.Pid); + } + else + { + gameProcess = new ExistingProcess((IntPtr)dalamudConsoleOutput.Handle); + } + + try + { + Log.Verbose($"Got game process handle {gameProcess.Handle} with pid {gameProcess.Id}"); + } + catch (InvalidOperationException ex) + { + Log.Error(ex, $"Dalamud returned invalid process handle {gameProcess.Handle}, attempting to recover by creating a new one from pid {dalamudConsoleOutput.Pid}..."); + gameProcess = Process.GetProcessById(dalamudConsoleOutput.Pid); + Log.Warning($"Recovered with process handle {gameProcess.Handle}"); + } + + if (gameProcess.Id != dalamudConsoleOutput.Pid) + Log.Warning($"Internal Process ID {gameProcess.Id} does not match Dalamud provided one {dalamudConsoleOutput.Pid}"); + + return gameProcess; + } + catch (JsonException ex) + { + Log.Error(ex, $"Couldn't parse Dalamud output: {output}"); + return null; + } + } + catch (Exception ex) + { + throw new DalamudRunnerException("Error trying to start Dalamud.", ex); + } + } + + /// + /// DUPLICATE_* values for DuplicateHandle's dwDesiredAccess. + /// + [Flags] + private enum DuplicateOptions : uint { + /// + /// Closes the source handle. This occurs regardless of any error status returned. + /// + CloseSource = 0x00000001, + + /// + /// Ignores the dwDesiredAccess parameter. The duplicate handle has the same access as the source handle. + /// + SameAccess = 0x00000002, + } + + /// + /// Duplicates an object handle. + /// + /// + /// A handle to the process with the handle to be duplicated. + /// + /// The handle must have the PROCESS_DUP_HANDLE access right. + /// + /// + /// The handle to be duplicated. This is an open object handle that is valid in the context of the source process. + /// For a list of objects whose handles can be duplicated, see the following Remarks section. + /// + /// + /// A handle to the process that is to receive the duplicated handle. + /// + /// The handle must have the PROCESS_DUP_HANDLE access right. + /// + /// + /// A pointer to a variable that receives the duplicate handle. This handle value is valid in the context of the target process. + /// + /// If hSourceHandle is a pseudo handle returned by GetCurrentProcess or GetCurrentThread, DuplicateHandle converts it to a real handle to a process or thread, respectively. + /// + /// If lpTargetHandle is NULL, the function duplicates the handle, but does not return the duplicate handle value to the caller. This behavior exists only for backward compatibility with previous versions of this function. You should not use this feature, as you will lose system resources until the target process terminates. + /// + /// This parameter is ignored if hTargetProcessHandle is NULL. + /// + /// + /// The access requested for the new handle. For the flags that can be specified for each object type, see the following Remarks section. + /// + /// This parameter is ignored if the dwOptions parameter specifies the DUPLICATE_SAME_ACCESS flag. Otherwise, the flags that can be specified depend on the type of object whose handle is to be duplicated. + /// + /// This parameter is ignored if hTargetProcessHandle is NULL. + /// + /// + /// A variable that indicates whether the handle is inheritable. If TRUE, the duplicate handle can be inherited by new processes created by the target process. If FALSE, the new handle cannot be inherited. + /// + /// This parameter is ignored if hTargetProcessHandle is NULL. + /// + /// + /// Optional actions. + /// + /// + /// If the function succeeds, the return value is nonzero. + /// + /// If the function fails, the return value is zero. To get extended error information, call GetLastError. + /// + /// + /// See https://docs.microsoft.com/en-us/windows/win32/api/handleapi/nf-handleapi-duplicatehandle. + /// + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DuplicateHandle( + IntPtr hSourceProcessHandle, + IntPtr hSourceHandle, + IntPtr hTargetProcessHandle, + out IntPtr lpTargetHandle, + uint dwDesiredAccess, + [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, + DuplicateOptions dwOptions); + + private static Process? GetInheritableCurrentProcessHandle() { + if (!DuplicateHandle(Process.GetCurrentProcess().Handle, Process.GetCurrentProcess().Handle, Process.GetCurrentProcess().Handle, out var inheritableCurrentProcessHandle, 0, true, DuplicateOptions.SameAccess)) { + Log.Error("Failed to call DuplicateHandle: Win32 error code {0}", Marshal.GetLastWin32Error()); + return null; + } + + return new ExistingProcess(inheritableCurrentProcessHandle); + } +} diff --git a/src/XIVLauncher2.Common.Windows/WindowsGameRunner.cs b/src/XIVLauncher2.Common.Windows/WindowsGameRunner.cs new file mode 100644 index 0000000..bbeb895 --- /dev/null +++ b/src/XIVLauncher2.Common.Windows/WindowsGameRunner.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using XIVLauncher.Common.Game; +using XIVLauncher2.Common.Dalamud; +using XIVLauncher2.Common.PlatformAbstractions; + +namespace XIVLauncher2.Common.Windows; + +public class WindowsGameRunner : IGameRunner +{ + private readonly DalamudLauncher dalamudLauncher; + private readonly bool dalamudOk; + private readonly DirectoryInfo dotnetRuntimePath; + + public WindowsGameRunner(DalamudLauncher dalamudLauncher, bool dalamudOk, DirectoryInfo dotnetRuntimePath) + { + this.dalamudLauncher = dalamudLauncher; + this.dalamudOk = dalamudOk; + this.dotnetRuntimePath = dotnetRuntimePath; + } + + public Process Start(string path, string workingDirectory, string arguments, IDictionary environment, DpiAwareness dpiAwareness) + { + if (dalamudOk) + { + var compat = "RunAsInvoker "; + compat += dpiAwareness switch { + DpiAwareness.Aware => "HighDPIAware", + DpiAwareness.Unaware => "DPIUnaware", + _ => throw new ArgumentOutOfRangeException() + }; + environment.Add("__COMPAT_LAYER", compat); + + var prevDalamudRuntime = Environment.GetEnvironmentVariable("DALAMUD_RUNTIME"); + if (string.IsNullOrWhiteSpace(prevDalamudRuntime)) + environment.Add("DALAMUD_RUNTIME", dotnetRuntimePath.FullName); + + return this.dalamudLauncher.Run(new FileInfo(path), arguments, environment); + } + + return NativeAclFix.LaunchGame(workingDirectory, path, arguments, environment, dpiAwareness, process => { }); + } +} diff --git a/src/XIVLauncher2.Common.Windows/WindowsRestartManager.cs b/src/XIVLauncher2.Common.Windows/WindowsRestartManager.cs new file mode 100644 index 0000000..7a1b964 --- /dev/null +++ b/src/XIVLauncher2.Common.Windows/WindowsRestartManager.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using System.Text; +using Exception = System.Exception; + +// ReSharper disable FieldCanBeMadeReadOnly.Local +// ReSharper disable MemberCanBePrivate.Local + +namespace XIVLauncher2.Common.Windows; + +public class WindowsRestartManager : IDisposable +{ + public delegate void RmWriteStatusCallback(uint percentageCompleted); + + private const int RM_SESSION_KEY_LEN = 16; // sizeof GUID + private const int CCH_RM_SESSION_KEY = RM_SESSION_KEY_LEN * 2; + private const int CCH_RM_MAX_APP_NAME = 255; + private const int CCH_RM_MAX_SVC_NAME = 63; + private const int RM_INVALID_TS_SESSION = -1; + private const int RM_INVALID_PROCESS = -1; + private const int ERROR_MORE_DATA = 234; + + [StructLayout(LayoutKind.Sequential)] + public struct RmUniqueProcess + { + public int dwProcessId; // PID + public FILETIME ProcessStartTime; // Process creation time + } + + public enum RmAppType + { + /// + /// Application type cannot be classified in known categories + /// + RmUnknownApp = 0, + + /// + /// Application is a windows application that displays a top-level window + /// + RmMainWindow = 1, + + /// + /// Application is a windows app but does not display a top-level window + /// + RmOtherWindow = 2, + + /// + /// Application is an NT service + /// + RmService = 3, + + /// + /// Application is Explorer + /// + RmExplorer = 4, + + /// + /// Application is Console application + /// + RmConsole = 5, + + /// + /// Application is critical system process where a reboot is required to restart + /// + RmCritical = 1000, + } + + [Flags] + public enum RmRebootReason + { + /// + /// A system restart is not required. + /// + RmRebootReasonNone = 0x0, + + /// + /// The current user does not have sufficient privileges to shut down one or more processes. + /// + RmRebootReasonPermissionDenied = 0x1, + + /// + /// One or more processes are running in another Terminal Services session. + /// + RmRebootReasonSessionMismatch = 0x2, + + /// + /// A system restart is needed because one or more processes to be shut down are critical processes. + /// + RmRebootReasonCriticalProcess = 0x4, + + /// + /// A system restart is needed because one or more services to be shut down are critical services. + /// + RmRebootReasonCriticalService = 0x8, + + /// + /// A system restart is needed because the current process must be shut down. + /// + RmRebootReasonDetectedSelf = 0x10, + } + + [Flags] + private enum RmShutdownType + { + RmForceShutdown = 0x1, // Force app shutdown + RmShutdownOnlyRegistered = 0x10 // Only shutdown apps if all apps registered for restart + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct RmProcessInfo + { + public RmUniqueProcess UniqueProcess; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = CCH_RM_MAX_APP_NAME + 1)] + public string AppName; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = CCH_RM_MAX_SVC_NAME + 1)] + public string ServiceShortName; + + public RmAppType ApplicationType; + public int AppStatus; + public int TSSessionId; + + [MarshalAs(UnmanagedType.Bool)] + public bool bRestartable; + + public Process Process + { + get + { + try + { + Process process = Process.GetProcessById(UniqueProcess.dwProcessId); + long fileTime = process.StartTime.ToFileTime(); + + if ((uint)UniqueProcess.ProcessStartTime.dwLowDateTime != (uint)(fileTime & uint.MaxValue)) + return null; + + if ((uint)UniqueProcess.ProcessStartTime.dwHighDateTime != (uint)(fileTime >> 32)) + return null; + + return process; + } + catch (Exception) + { + return null; + } + } + } + } + + [DllImport("rstrtmgr", CharSet = CharSet.Unicode)] + private static extern int RmStartSession(out int dwSessionHandle, int sessionFlags, StringBuilder strSessionKey); + + [DllImport("rstrtmgr")] + private static extern int RmEndSession(int dwSessionHandle); + + [DllImport("rstrtmgr")] + private static extern int RmShutdown(int dwSessionHandle, RmShutdownType lAtionFlags, RmWriteStatusCallback fnStatus); + + [DllImport("rstrtmgr")] + private static extern int RmRestart(int dwSessionHandle, int dwRestartFlags, RmWriteStatusCallback fnStatus); + + [DllImport("rstrtmgr")] + private static extern int RmGetList(int dwSessionHandle, out int nProcInfoNeeded, ref int nProcInfo, [In, Out] RmProcessInfo[] rgAffectedApps, out RmRebootReason dwRebootReasons); + + [DllImport("rstrtmgr", CharSet = CharSet.Unicode)] + private static extern int RmRegisterResources(int dwSessionHandle, + int nFiles, string[] rgsFileNames, + int nApplications, RmUniqueProcess[] rgApplications, + int nServices, string[] rgsServiceNames); + + private readonly int sessionHandle; + private readonly string sessionKey; + + public WindowsRestartManager() + { + var sessKey = new StringBuilder(CCH_RM_SESSION_KEY + 1); + ThrowOnFailure(RmStartSession(out sessionHandle, 0, sessKey)); + sessionKey = sessKey.ToString(); + } + + public void Register(IEnumerable files = null, IEnumerable processes = null, IEnumerable serviceNames = null) + { + string[] filesArray = files?.Select(f => f.FullName).ToArray() ?? Array.Empty(); + RmUniqueProcess[] processesArray = processes?.Select(f => new RmUniqueProcess + { + dwProcessId = f.Id, + ProcessStartTime = new FILETIME + { + dwLowDateTime = (int)(f.StartTime.ToFileTime() & uint.MaxValue), + dwHighDateTime = (int)(f.StartTime.ToFileTime() >> 32), + } + }).ToArray() ?? Array.Empty(); + string[] servicesArray = serviceNames?.ToArray() ?? Array.Empty(); + ThrowOnFailure(RmRegisterResources(sessionHandle, + filesArray.Length, filesArray, + processesArray.Length, processesArray, + servicesArray.Length, servicesArray)); + } + + public void Shutdown(bool forceShutdown = true, bool shutdownOnlyRegistered = false, RmWriteStatusCallback cb = null) + { + ThrowOnFailure(RmShutdown(sessionHandle, (forceShutdown ? RmShutdownType.RmForceShutdown : 0) | (shutdownOnlyRegistered ? RmShutdownType.RmShutdownOnlyRegistered : 0), cb)); + } + + public void Restart(RmWriteStatusCallback cb = null) + { + ThrowOnFailure(RmRestart(sessionHandle, 0, cb)); + } + + public List GetInterferingProcesses(out RmRebootReason rebootReason) + { + var count = 0; + var infos = new RmProcessInfo[count]; + var err = 0; + + for (var i = 0; i < 16; i++) + { + err = RmGetList(sessionHandle, out int needed, ref count, infos, out rebootReason); + + switch (err) + { + case 0: + return infos.Take(count).ToList(); + + case ERROR_MORE_DATA: + infos = new RmProcessInfo[count = needed]; + break; + + default: + ThrowOnFailure(err); + break; + } + } + + ThrowOnFailure(err); + + // should not reach + throw new InvalidOperationException(); + } + + private void ReleaseUnmanagedResources() + { + ThrowOnFailure(RmEndSession(sessionHandle)); + } + + public void Dispose() + { + ReleaseUnmanagedResources(); + GC.SuppressFinalize(this); + } + + ~WindowsRestartManager() + { + ReleaseUnmanagedResources(); + } + + private void ThrowOnFailure(int err) + { + if (err != 0) + throw new Win32Exception(err); + } +} diff --git a/src/XIVLauncher2.Common.Windows/WindowsSteam.cs b/src/XIVLauncher2.Common.Windows/WindowsSteam.cs new file mode 100644 index 0000000..2990a11 --- /dev/null +++ b/src/XIVLauncher2.Common.Windows/WindowsSteam.cs @@ -0,0 +1,175 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Win32; +using Serilog; +using Steamworks; +using XIVLauncher2.Common.Game.Exceptions; +using XIVLauncher2.Common.PlatformAbstractions; + +namespace XIVLauncher2.Common.Windows +{ + public class WindowsSteam : ISteam + { + private const int MAX_INIT_TRIES_AFTER_START = 15; + + public Task? AsyncStartTask { get; private set; } + + public WindowsSteam() + { + SteamUtils.OnGamepadTextInputDismissed += b => OnGamepadTextInputDismissed?.Invoke(b); + } + + public void KickoffAsyncStartup(uint appid) + { + AsyncStartTask = StartAndInitialize(appid); + } + + /// + /// Start Steam if not already running, and initialize our app. + /// + /// The app ID to init + private async Task StartAndInitialize(uint appId) + { + if (!Process.GetProcessesByName("steam").Any()) + StartSteam(); + + for (var i = 0; i < MAX_INIT_TRIES_AFTER_START; i++) + { + await Task.Delay(1000).ConfigureAwait(false); + + try + { + Initialize(appId); + + Log.Verbose("Steam started automatically"); + return; + } + catch (Exception ex) + { + Log.Verbose(ex, "Steam not ready yet, waiting a little longer..."); + } + } + + throw new SteamStartupTimedOutException(); + } + + public class SteamStartupTimedOutException : Exception + { + public SteamStartupTimedOutException() + : base("Could not init Steam in time") + { + } + } + + private static void StartSteam() + { + var path = FindSteam(); + + if (path == null || !path.Exists) + throw new SteamException($"Failed to find Steam at {path}"); + + var args = "-silent"; + + if (EnvironmentSettings.IsOpenSteamMinimal) + { + args += " -no-browser"; + } + + var psi = new ProcessStartInfo + { + FileName = path.FullName, + Arguments = args, + }; + + try + { + Process.Start(psi); + } + catch (Exception ex) + { + throw new SteamException("Steam Process.Start failed", ex); + } + } + + private static FileInfo? FindSteam() + { + var regValue = Registry.GetValue("HKEY_CLASSES_ROOT\\steam\\Shell\\Open\\Command", null, null) as string; + + if (regValue == null || !regValue.Contains("\"")) + return null; + + return new FileInfo(regValue.Substring(1, regValue.IndexOf('"', 1) - 1)); + } + + public void Initialize(uint appId) + { + // workaround because SetEnvironmentVariable doesn't actually touch the process environment on unix + if (Environment.OSVersion.Platform == PlatformID.Unix) + { + [System.Runtime.InteropServices.DllImport("c")] + static extern int setenv(string name, string value, int overwrite); + + setenv("SteamAppId", appId.ToString(), 1); + } + + SteamClient.Init(appId); + } + + public bool IsValid => SteamClient.IsValid; + + public bool BLoggedOn => SteamClient.IsLoggedOn; + + public bool BOverlayNeedsPresent => SteamUtils.DoesOverlayNeedPresent; + + public void Shutdown() + { + SteamClient.Shutdown(); + } + + public async Task GetAuthSessionTicketAsync() + { + var ticket = await SteamUser.GetAuthSessionTicketAsync().ConfigureAwait(true); + return ticket?.Data; + } + + public bool IsAppInstalled(uint appId) + { + return SteamApps.IsAppInstalled(appId); + } + + public string GetAppInstallDir(uint appId) + { + return SteamApps.AppInstallDir(appId); + } + + public bool ShowGamepadTextInput(bool password, bool multiline, string description, int maxChars, string existingText = "") + { + return SteamUtils.ShowGamepadTextInput(password ? GamepadTextInputMode.Password : GamepadTextInputMode.Normal, multiline ? GamepadTextInputLineMode.MultipleLines : GamepadTextInputLineMode.SingleLine, description, maxChars, existingText); + } + + public string GetEnteredGamepadText() + { + return SteamUtils.GetEnteredGamepadText(); + } + + public bool ShowFloatingGamepadTextInput(ISteam.EFloatingGamepadTextInputMode mode, int x, int y, int width, int height) + { + // Facepunch.Steamworks doesn't have this... + return false; + } + + public bool IsRunningOnSteamDeck() => SteamUtils.IsRunningOnSteamDeck; + + public uint GetServerRealTime() => (uint)((DateTimeOffset)SteamUtils.SteamServerTime).ToUnixTimeSeconds(); + + public void ActivateGameOverlayToWebPage(string url, bool modal = false) + { + SteamFriends.OpenWebOverlay(url, modal); + } + + public event Action OnGamepadTextInputDismissed; + } +} diff --git a/src/XIVLauncher2.Common.Windows/XIVLauncher2.Common.Windows.csproj b/src/XIVLauncher2.Common.Windows/XIVLauncher2.Common.Windows.csproj new file mode 100644 index 0000000..fe3a72e --- /dev/null +++ b/src/XIVLauncher2.Common.Windows/XIVLauncher2.Common.Windows.csproj @@ -0,0 +1,44 @@ + + + XIVLauncher2.Common.Windows + XIVLauncher2.Common.Windows + Shared XIVLauncher platform-specific implementations for Windows. + 2.0.0 + disable + + + + Library + net7.0 + latest + true + true + + + + + + + + $(MSBuildProjectDirectory)\ + $(AppOutputBase)=C:\goatsoft\xl\XIVLauncher.Common.Windows\ + + + + + + PreserveNewest + + + + PreserveNewest + + + + + + + + + + diff --git a/src/XIVLauncher2.Common.Windows/steam_api.dll b/src/XIVLauncher2.Common.Windows/steam_api.dll new file mode 100644 index 0000000..319bb83 Binary files /dev/null and b/src/XIVLauncher2.Common.Windows/steam_api.dll differ diff --git a/src/XIVLauncher2.Common.Windows/steam_api64.dll b/src/XIVLauncher2.Common.Windows/steam_api64.dll new file mode 100644 index 0000000..e1ca692 Binary files /dev/null and b/src/XIVLauncher2.Common.Windows/steam_api64.dll differ diff --git a/src/XIVLauncher2.Common/Addon/AddonEntry.cs b/src/XIVLauncher2.Common/Addon/AddonEntry.cs new file mode 100644 index 0000000..ced226a --- /dev/null +++ b/src/XIVLauncher2.Common/Addon/AddonEntry.cs @@ -0,0 +1,10 @@ +using XIVLauncher2.Common.Addon.Implementations; + +namespace XIVLauncher2.Common.Addon +{ + public class AddonEntry + { + public bool IsEnabled { get; set; } + public GenericAddon Addon { get; set; } + } +} diff --git a/src/XIVLauncher2.Common/Addon/AddonManager.cs b/src/XIVLauncher2.Common/Addon/AddonManager.cs new file mode 100644 index 0000000..ed08731 --- /dev/null +++ b/src/XIVLauncher2.Common/Addon/AddonManager.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Serilog; + +namespace XIVLauncher2.Common.Addon +{ + public class AddonManager + { + private List> _runningAddons; + + public bool IsRunning { get; private set; } + + public void RunAddons(int gamePid, List addonEntries) + { + if (_runningAddons != null) + throw new Exception("Addons still running?"); + + _runningAddons = new List>(); + + foreach (var addonEntry in addonEntries) + { + addonEntry.Setup(gamePid); + + if (addonEntry is IPersistentAddon persistentAddon) + { + Log.Information("Starting PersistentAddon {0}", persistentAddon.Name); + var cancellationTokenSource = new CancellationTokenSource(); + + var addonThread = new Thread(persistentAddon.DoWork); + addonThread.Start(cancellationTokenSource.Token); + + _runningAddons.Add(new Tuple(persistentAddon, addonThread, cancellationTokenSource)); + } + + if (addonEntry is IRunnableAddon runnableAddon) + { + Log.Information("Starting RunnableAddon {0}", runnableAddon.Name); + runnableAddon.Run(); + } + + if (addonEntry is INotifyAddonAfterClose notifiedAddon) + _runningAddons.Add(new Tuple(notifiedAddon, null, null)); + } + + IsRunning = true; + } + + public void StopAddons() + { + Log.Information("Stopping addons..."); + + if (_runningAddons != null) + { + foreach (var addon in _runningAddons) + { + addon.Item3?.Cancel(); + addon.Item2?.Join(); + + if (addon.Item1 is INotifyAddonAfterClose notifiedAddon) + notifiedAddon.GameClosed(); + } + + _runningAddons = null; + } + + IsRunning = false; + } + } +} diff --git a/src/XIVLauncher2.Common/Addon/IAddon.cs b/src/XIVLauncher2.Common/Addon/IAddon.cs new file mode 100644 index 0000000..b0bb062 --- /dev/null +++ b/src/XIVLauncher2.Common/Addon/IAddon.cs @@ -0,0 +1,9 @@ +namespace XIVLauncher2.Common.Addon +{ + public interface IAddon + { + string Name { get; } + + void Setup(int gamePid); + } +} diff --git a/src/XIVLauncher2.Common/Addon/INotifyAddonAfterClose.cs b/src/XIVLauncher2.Common/Addon/INotifyAddonAfterClose.cs new file mode 100644 index 0000000..24fbb18 --- /dev/null +++ b/src/XIVLauncher2.Common/Addon/INotifyAddonAfterClose.cs @@ -0,0 +1,7 @@ +namespace XIVLauncher2.Common.Addon +{ + interface INotifyAddonAfterClose : IAddon + { + void GameClosed(); + } +} diff --git a/src/XIVLauncher2.Common/Addon/IPersistentAddon.cs b/src/XIVLauncher2.Common/Addon/IPersistentAddon.cs new file mode 100644 index 0000000..34018ba --- /dev/null +++ b/src/XIVLauncher2.Common/Addon/IPersistentAddon.cs @@ -0,0 +1,7 @@ +namespace XIVLauncher2.Common.Addon +{ + interface IPersistentAddon : IAddon + { + void DoWork(object state); + } +} diff --git a/src/XIVLauncher2.Common/Addon/IRunnableAddon.cs b/src/XIVLauncher2.Common/Addon/IRunnableAddon.cs new file mode 100644 index 0000000..734ff9b --- /dev/null +++ b/src/XIVLauncher2.Common/Addon/IRunnableAddon.cs @@ -0,0 +1,7 @@ +namespace XIVLauncher2.Common.Addon +{ + interface IRunnableAddon : IAddon + { + void Run(); + } +} diff --git a/src/XIVLauncher2.Common/Addon/Implementations/GenericAddon.cs b/src/XIVLauncher2.Common/Addon/Implementations/GenericAddon.cs new file mode 100644 index 0000000..a115140 --- /dev/null +++ b/src/XIVLauncher2.Common/Addon/Implementations/GenericAddon.cs @@ -0,0 +1,208 @@ +using Serilog; +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace XIVLauncher2.Common.Addon.Implementations +{ + public class GenericAddon : IRunnableAddon, INotifyAddonAfterClose + { + private Process _addonProcess; + + void IAddon.Setup(int gamePid) + { + } + + public void Run() => + Run(false); + + private void Run(bool gameClosed) + { + if (string.IsNullOrEmpty(Path)) + { + Log.Error("Generic addon path was null."); + return; + } + + if (RunOnClose && !gameClosed) + return; // This Addon only runs when the game is closed. + + try + { + var ext = System.IO.Path.GetExtension(Path).ToLower(); + + switch (ext) + { + case ".ps1": + RunPowershell(); + break; + + case ".bat": + RunBatch(); + break; + + default: + RunApp(); + break; + } + + Log.Information("Launched addon {0}.", System.IO.Path.GetFileNameWithoutExtension(Path)); + } + catch (Exception e) + { + Log.Error(e, "Could not launch generic addon."); + } + } + + public void GameClosed() + { + if (RunOnClose) + { + Run(true); + } + + if (!RunAsAdmin) + { + try + { + if (_addonProcess == null) + return; + + if (_addonProcess.Handle == IntPtr.Zero) + return; + + if (!_addonProcess.HasExited && KillAfterClose) + { + if (!_addonProcess.CloseMainWindow() || !_addonProcess.WaitForExit(1000)) + _addonProcess.Kill(); + + _addonProcess.Close(); + } + } + catch (Exception ex) + { + Log.Information(ex, "Could not kill addon process."); + } + } + } + + private void RunApp() + { + // If there already is a process like this running - we don't need to spawn another one. + if (Process.GetProcessesByName(System.IO.Path.GetFileNameWithoutExtension(Path)).Any()) + { + Log.Information("Addon {0} is already running.", Name); + return; + } + + _addonProcess = new Process + { + StartInfo = + { + FileName = Path, + Arguments = CommandLine, + WorkingDirectory = System.IO.Path.GetDirectoryName(Path), + }, + }; + + if (RunAsAdmin) + // Vista or higher check + // https://stackoverflow.com/a/2532775 + if (Environment.OSVersion.Version.Major >= 6) _addonProcess.StartInfo.Verb = "runas"; + + _addonProcess.StartInfo.WindowStyle = ProcessWindowStyle.Minimized; + + _addonProcess.Start(); + } + + private void RunPowershell() + { + var ps = new ProcessStartInfo + { + FileName = Powershell, + WorkingDirectory = System.IO.Path.GetDirectoryName(Path), + Arguments = $@"-File ""{Path}"" {CommandLine}", + UseShellExecute = false, + }; + + RunScript(ps); + } + + private void RunBatch() + { + var ps = new ProcessStartInfo + { + FileName = Environment.GetEnvironmentVariable("ComSpec"), + WorkingDirectory = System.IO.Path.GetDirectoryName(Path), + Arguments = $@"/C ""{Path}"" {CommandLine}", + UseShellExecute = false, + }; + + RunScript(ps); + } + + private void RunScript(ProcessStartInfo ps) + { + ps.WindowStyle = ProcessWindowStyle.Hidden; + ps.CreateNoWindow = true; + + if (RunAsAdmin) + // Vista or higher check + // https://stackoverflow.com/a/2532775 + if (Environment.OSVersion.Version.Major >= 6) ps.Verb = "runas"; + + try + { + _addonProcess = Process.Start(ps); + Log.Information("Launched addon {0}.", System.IO.Path.GetFileNameWithoutExtension(Path)); + } + catch (Win32Exception exc) + { + // If the user didn't cause this manually by dismissing the UAC prompt, we throw it + if ((uint)exc.HResult != 0x80004005) + throw; + } + } + + public string Name => + string.IsNullOrEmpty(Path) + ? "Invalid addon" + : $"Launch{(IsApp ? " EXE" : string.Empty)} : {System.IO.Path.GetFileNameWithoutExtension(Path)}"; + + private bool IsApp => + !string.IsNullOrEmpty(Path) && + System.IO.Path.GetExtension(Path).ToLower() == ".exe"; + + public string Path; + public string CommandLine; + public bool RunAsAdmin; + public bool RunOnClose; + public bool KillAfterClose; + + private static readonly Lazy LazyPowershell = new(GetPowershell); + + private static string Powershell => LazyPowershell.Value; + + private static string GetPowershell() + { + var result = "powershell.exe"; + + var path = Environment.GetEnvironmentVariable("Path"); + var values = path?.Split(';') ?? Array.Empty(); + + foreach (var dir in values) + { + var powershell = System.IO.Path.Combine(dir, "pwsh.exe"); + if (File.Exists(powershell)) + { + result = powershell; + break; + } + } + + return result; + } + } +} diff --git a/src/XIVLauncher2.Common/ClientLanguage.cs b/src/XIVLauncher2.Common/ClientLanguage.cs new file mode 100644 index 0000000..653d655 --- /dev/null +++ b/src/XIVLauncher2.Common/ClientLanguage.cs @@ -0,0 +1,39 @@ +using XIVLauncher2.Common.Util; + +namespace XIVLauncher2.Common +{ + public enum ClientLanguage + { + Japanese, + English, + German, + French + } + + public static class ClientLanguageExtensions + { + public static string GetLangCode(this ClientLanguage language, bool forceNa = false) + { + switch (language) + { + case ClientLanguage.Japanese: + return "ja"; + + case ClientLanguage.English when GameHelpers.IsRegionNorthAmerica() || forceNa: + return "en-us"; + + case ClientLanguage.English: + return "en-gb"; + + case ClientLanguage.German: + return "de"; + + case ClientLanguage.French: + return "fr"; + + default: + return "en-gb"; + } + } + } +} diff --git a/src/XIVLauncher2.Common/CommonUniqueIdCache.cs b/src/XIVLauncher2.Common/CommonUniqueIdCache.cs new file mode 100644 index 0000000..7eb7f74 --- /dev/null +++ b/src/XIVLauncher2.Common/CommonUniqueIdCache.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using XIVLauncher2.Common.PlatformAbstractions; + +namespace XIVLauncher.PlatformAbstractions +{ + public class CommonUniqueIdCache : IUniqueIdCache + { + private const int DAYS_TO_TIMEOUT = 1; + + private List _cache; + + public CommonUniqueIdCache(FileInfo saveFile) + { + this.configFile = saveFile; + + Load(); + } + + #region SaveLoad + + private readonly FileInfo configFile; + + public void Save() => + File.WriteAllText(configFile.FullName, JsonSerializer.Serialize(_cache, new JsonSerializerOptions { WriteIndented = true })); + + public void Load() + { + if (!File.Exists(configFile.FullName)) + { + _cache = new List(); + return; + } + + _cache = JsonSerializer.Deserialize>(File.ReadAllText(configFile.FullName)) ?? new List(); + } + + public void Reset() + { + _cache.Clear(); + Save(); + } + + #endregion + + private void DeleteOldCaches() + { + _cache.RemoveAll(entry => (DateTime.Now - entry.CreationDate).TotalDays > DAYS_TO_TIMEOUT); + } + + public bool HasValidCache(string userName) + { + return _cache.Any(entry => IsValidCache(entry, userName)); + } + + public void Add(string userName, string uid, int region, int expansionLevel) + { + _cache.Add(new UniqueIdCacheEntry + { + CreationDate = DateTime.Now, + UserName = userName, + UniqueId = uid, + Region = region, + ExpansionLevel = expansionLevel + }); + + Save(); + } + + public bool TryGet(string userName, out IUniqueIdCache.CachedUid cached) + { + DeleteOldCaches(); + + var cache = _cache.FirstOrDefault(entry => IsValidCache(entry, userName)); + + if (cache == null) + { + cached = default; + return false; + } + + cached = new IUniqueIdCache.CachedUid + { + UniqueId = cache.UniqueId, + Region = cache.Region, + MaxExpansion = cache.ExpansionLevel, + }; + return true; + } + + private bool IsValidCache(UniqueIdCacheEntry entry, string name) => entry.UserName == name && + (DateTime.Now - entry.CreationDate).TotalDays <= + DAYS_TO_TIMEOUT; + + public class UniqueIdCacheEntry + { + public string UserName { get; set; } + public string UniqueId { get; set; } + public int Region { get; set; } + public int ExpansionLevel { get; set; } + + public DateTime CreationDate { get; set; } + } + } +} diff --git a/src/XIVLauncher2.Common/Constants.cs b/src/XIVLauncher2.Common/Constants.cs new file mode 100644 index 0000000..63cf687 --- /dev/null +++ b/src/XIVLauncher2.Common/Constants.cs @@ -0,0 +1,32 @@ +using System; +using XIVLauncher2.Common.Util; + +namespace XIVLauncher2.Common +{ + public static class Constants + { + public const string BASE_GAME_VERSION = "2012.01.01.0000.0000"; + + public const uint STEAM_APP_ID = 39210; + public const uint STEAM_FT_APP_ID = 312060; + + public static string PatcherUserAgent => GetPatcherUserAgent(PlatformHelpers.GetPlatform()); + + private static string GetPatcherUserAgent(Platform platform) + { + switch (platform) + { + case Platform.Win32: + case Platform.Win32OnLinux: + case Platform.Linux: + return "FFXIV PATCH CLIENT"; + + case Platform.Mac: + return "FFXIV-MAC PATCH CLIENT"; + + default: + throw new ArgumentOutOfRangeException(nameof(platform), platform, null); + } + } + } +} diff --git a/src/XIVLauncher2.Common/Dalamud/AssetManager.cs b/src/XIVLauncher2.Common/Dalamud/AssetManager.cs new file mode 100644 index 0000000..b6c7ad9 --- /dev/null +++ b/src/XIVLauncher2.Common/Dalamud/AssetManager.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using Serilog; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using XIVLauncher2.Common.Util; + +namespace XIVLauncher2.Common.Dalamud +{ + public class AssetManager + { + private const string ASSET_STORE_URL = "https://kamori.goats.dev/Dalamud/Asset/Meta"; + + internal class AssetInfo + { + [JsonPropertyName("version")] + public int Version { get; set; } + + [JsonPropertyName("assets")] + public IReadOnlyList Assets { get; set; } + + public class Asset + { + [JsonPropertyName("url")] + public string Url { get; set; } + + [JsonPropertyName("fileName")] + public string FileName { get; set; } + + [JsonPropertyName("hash")] + public string Hash { get; set; } + } + } + + public static async Task EnsureAssets(DirectoryInfo baseDir, bool forceProxy) + { + using var client = new HttpClient + { + Timeout = TimeSpan.FromMinutes(4), + }; + + client.DefaultRequestHeaders.CacheControl = new CacheControlHeaderValue + { + NoCache = true, + }; + + using var sha1 = SHA1.Create(); + + Log.Verbose("[DASSET] Starting asset download"); + + var (isRefreshNeeded, info) = CheckAssetRefreshNeeded(baseDir); + + // NOTE(goat): We should use a junction instead of copying assets to a new folder. There is no C# API for junctions in .NET Framework. + + var assetsDir = new DirectoryInfo(Path.Combine(baseDir.FullName, info.Version.ToString())); + var devDir = new DirectoryInfo(Path.Combine(baseDir.FullName, "dev")); + + foreach (var entry in info.Assets) + { + var filePath = Path.Combine(assetsDir.FullName, entry.FileName); + var filePathDev = Path.Combine(devDir.FullName, entry.FileName); + + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(filePathDev)!); + } + catch + { + // ignored + } + + var refreshFile = false; + + if (File.Exists(filePath) && !string.IsNullOrEmpty(entry.Hash)) + { + try + { + using var file = File.OpenRead(filePath); + var fileHash = sha1.ComputeHash(file); + var stringHash = BitConverter.ToString(fileHash).Replace("-", ""); + refreshFile = stringHash != entry.Hash; + Log.Verbose("[DASSET] {0} has {1}, remote {2}", entry.FileName, stringHash, entry.Hash); + } + catch (Exception ex) + { + Log.Error(ex, "[DASSET] Could not read asset"); + } + } + + if (!File.Exists(filePath) || isRefreshNeeded || refreshFile) + { + var url = entry.Url; + + if (forceProxy && url.Contains("/File/Get/")) + { + url = url.Replace("/File/Get/", "/File/GetProxy/"); + } + + Log.Verbose("[DASSET] Downloading {0} to {1}...", url, entry.FileName); + + var request = await client.GetAsync(url).ConfigureAwait(true); + request.EnsureSuccessStatusCode(); + File.WriteAllBytes(filePath, await request.Content.ReadAsByteArrayAsync().ConfigureAwait(true)); + + try + { + File.Copy(filePath, filePathDev, true); + } + catch + { + // ignored + } + } + } + + if (isRefreshNeeded) + SetLocalAssetVer(baseDir, info.Version); + + Log.Verbose("[DASSET] Assets OK at {0}", assetsDir.FullName); + + CleanUpOld(baseDir, info.Version - 1); + + return assetsDir; + } + + private static string GetAssetVerPath(DirectoryInfo baseDir) + { + return Path.Combine(baseDir.FullName, "asset.ver"); + } + + /// + /// Check if an asset update is needed. When this fails, just return false - the route to github + /// might be bad, don't wanna just bail out in that case + /// + /// Base directory for assets + /// Update state + private static (bool isRefreshNeeded, AssetInfo info) CheckAssetRefreshNeeded(DirectoryInfo baseDir) + { + using var client = new WebClient(); + + var localVerFile = GetAssetVerPath(baseDir); + var localVer = 0; + + try + { + if (File.Exists(localVerFile)) + localVer = int.Parse(File.ReadAllText(localVerFile)); + } + catch (Exception ex) + { + // This means it'll stay on 0, which will redownload all assets - good by me + Log.Error(ex, "[DASSET] Could not read asset.ver"); + } + + var remoteVer = JsonSerializer.Deserialize(client.DownloadString(ASSET_STORE_URL)); + + Log.Verbose("[DASSET] Ver check - local:{0} remote:{1}", localVer, remoteVer.Version); + + var needsUpdate = remoteVer.Version > localVer; + + return (needsUpdate, remoteVer); + } + + private static void SetLocalAssetVer(DirectoryInfo baseDir, int version) + { + try + { + var localVerFile = GetAssetVerPath(baseDir); + File.WriteAllText(localVerFile, version.ToString()); + } + catch (Exception e) + { + Log.Error(e, "[DASSET] Could not write local asset version"); + } + } + + private static void CleanUpOld(DirectoryInfo baseDir, int version) + { + if (GameHelpers.CheckIsGameOpen()) + return; + + var toDelete = Path.Combine(baseDir.FullName, version.ToString()); + + try + { + if (Directory.Exists(toDelete)) + Directory.Delete(toDelete, true); + } + catch (Exception ex) + { + Log.Error(ex, "Could not clean up old assets"); + } + } + } +} diff --git a/src/XIVLauncher2.Common/Dalamud/DalamudConsoleOutput.cs b/src/XIVLauncher2.Common/Dalamud/DalamudConsoleOutput.cs new file mode 100644 index 0000000..ceabdae --- /dev/null +++ b/src/XIVLauncher2.Common/Dalamud/DalamudConsoleOutput.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace XIVLauncher2.Common.Dalamud +{ + public sealed class DalamudConsoleOutput + { + [JsonPropertyName("pid")] + public int Pid { get; set; } + + [JsonPropertyName("handle")] + public long Handle { get; set; } + } +} diff --git a/src/XIVLauncher2.Common/Dalamud/DalamudInjectorArgs.cs b/src/XIVLauncher2.Common/Dalamud/DalamudInjectorArgs.cs new file mode 100644 index 0000000..5cb08d2 --- /dev/null +++ b/src/XIVLauncher2.Common/Dalamud/DalamudInjectorArgs.cs @@ -0,0 +1,22 @@ +namespace XIVLauncher2.Common.Dalamud +{ + public static class DalamudInjectorArgs + { + public const string Launch = "launch"; + public const string WithoutDalamud = "--without-dalamud"; + public const string FakeArguments = "--fake-arguments"; + public const string NoPlugin = "--no-plugin"; + public const string NoThirdParty = "--no-3rd-plugin"; + public static string Mode(string method) => $"--mode={method}"; + public static string Game(string path) => $"--game=\"{path}\""; + public static string HandleOwner(long handle) => $"--handle-owner={handle}"; + public static string WorkingDirectory(string path) => $"--dalamud-working-directory=\"{path}\""; + public static string ConfigurationPath(string path) => $"--dalamud-configuration-path=\"{path}\""; + public static string PluginDirectory(string path) => $"--dalamud-plugin-directory=\"{path}\""; + public static string PluginDevDirectory(string path) => $"--dalamud-dev-plugin-directory=\"{path}\""; + public static string AssetDirectory(string path) => $"--dalamud-asset-directory=\"{path}\""; + public static string ClientLanguage(int language) => $"--dalamud-client-language={language}"; + public static string DelayInitialize(int delay) => $"--dalamud-delay-initialize={delay}"; + public static string TSPackB64(string data) => $"--dalamud-tspack-b64={data}"; + } +} diff --git a/src/XIVLauncher2.Common/Dalamud/DalamudLauncher.cs b/src/XIVLauncher2.Common/Dalamud/DalamudLauncher.cs new file mode 100644 index 0000000..0865c67 --- /dev/null +++ b/src/XIVLauncher2.Common/Dalamud/DalamudLauncher.cs @@ -0,0 +1,170 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Threading; +using System.Text.Json; +using Serilog; +using XIVLauncher2.Common.PlatformAbstractions; + +namespace XIVLauncher2.Common.Dalamud +{ + public class DalamudLauncher + { + private readonly DalamudLoadMethod loadMethod; + private readonly DirectoryInfo gamePath; + private readonly DirectoryInfo configDirectory; + private readonly ClientLanguage language; + private readonly IDalamudRunner runner; + private readonly DalamudUpdater updater; + private readonly int injectionDelay; + private readonly bool fakeLogin; + private readonly bool noPlugin; + private readonly bool noThirdPlugin; + private readonly string troubleshootingData; + + public enum DalamudInstallState + { + Ok, + Failed, + OutOfDate, + } + + public DalamudLauncher(IDalamudRunner runner, DalamudUpdater updater, DalamudLoadMethod loadMethod, DirectoryInfo gamePath, DirectoryInfo configDirectory, ClientLanguage clientLanguage, int injectionDelay, bool fakeLogin, bool noPlugin, bool noThirdPlugin, string troubleshootingData) + { + this.runner = runner; + this.updater = updater; + this.loadMethod = loadMethod; + this.gamePath = gamePath; + this.configDirectory = configDirectory; + this.language = clientLanguage; + this.injectionDelay = injectionDelay; + this.fakeLogin = fakeLogin; + this.noPlugin = noPlugin; + this.noThirdPlugin = noThirdPlugin; + this.troubleshootingData = troubleshootingData; + } + + public const string REMOTE_BASE = "https://kamori.goats.dev/Dalamud/Release/VersionInfo?track="; + + public DalamudInstallState HoldForUpdate(DirectoryInfo gamePath) + { + Log.Information("[HOOKS] DalamudLauncher::HoldForUpdate(gp:{0})", gamePath.FullName); + + if (this.updater.State != DalamudUpdater.DownloadState.Done) + this.updater.ShowOverlay(); + + while (this.updater.State != DalamudUpdater.DownloadState.Done) + { + if (this.updater.State == DalamudUpdater.DownloadState.Failed) + { + this.updater.CloseOverlay(); + return DalamudInstallState.Failed; + } + + if (this.updater.State == DalamudUpdater.DownloadState.NoIntegrity) + { + this.updater.CloseOverlay(); + throw new DalamudRunnerException("No runner integrity"); + } + + Thread.Yield(); + } + + if (!this.updater.Runner.Exists) + throw new DalamudRunnerException("Runner not present"); + + if (!ReCheckVersion(gamePath)) + { + this.updater.SetOverlayProgress(IDalamudLoadingOverlay.DalamudUpdateStep.Unavailable); + this.updater.ShowOverlay(); + Log.Error("[HOOKS] ReCheckVersion fail"); + + return DalamudInstallState.OutOfDate; + } + + return DalamudInstallState.Ok; + } + + public Process Run(FileInfo gameExe, string gameArgs, IDictionary environment) + { + Log.Information("[HOOKS] DalamudLauncher::Run(gp:{0}, cl:{1})", this.gamePath.FullName, this.language); + + var ingamePluginPath = Path.Combine(this.configDirectory.FullName, "installedPlugins"); + var defaultPluginPath = Path.Combine(this.configDirectory.FullName, "devPlugins"); + + Directory.CreateDirectory(ingamePluginPath); + Directory.CreateDirectory(defaultPluginPath); + + var startInfo = new DalamudStartInfo + { + Language = language, + PluginDirectory = ingamePluginPath, + DefaultPluginDirectory = defaultPluginPath, + ConfigurationPath = DalamudSettings.GetConfigPath(this.configDirectory), + AssetDirectory = this.updater.AssetDirectory.FullName, + GameVersion = Repository.Ffxiv.GetVer(gamePath), + WorkingDirectory = this.updater.Runner.Directory?.FullName, + DelayInitializeMs = this.injectionDelay, + TroubleshootingPackData = this.troubleshootingData, + }; + + if (this.loadMethod != DalamudLoadMethod.ACLonly) + Log.Information("[HOOKS] DelayInitializeMs: {0}", startInfo.DelayInitializeMs); + + switch (this.loadMethod) + { + case DalamudLoadMethod.EntryPoint: + Log.Verbose("[HOOKS] Now running OEP rewrite"); + break; + + case DalamudLoadMethod.DllInject: + Log.Verbose("[HOOKS] Now running DLL inject"); + break; + + case DalamudLoadMethod.ACLonly: + Log.Verbose("[HOOKS] Now running ACL-only fix without injection"); + break; + } + + var process = this.runner.Run(this.updater.Runner, this.fakeLogin, this.noPlugin, this.noThirdPlugin, gameExe, gameArgs, environment, this.loadMethod, startInfo); + + this.updater.CloseOverlay(); + + if (this.loadMethod != DalamudLoadMethod.ACLonly) + Log.Information("[HOOKS] Started dalamud!"); + + return process; + } + + private bool ReCheckVersion(DirectoryInfo gamePath) + { + if (this.updater.State != DalamudUpdater.DownloadState.Done) + return false; + + if (this.updater.RunnerOverride != null) + return true; + + var info = DalamudVersionInfo.Load(new FileInfo(Path.Combine(this.updater.Runner.DirectoryName!, + "version.json"))); + + if (Repository.Ffxiv.GetVer(gamePath) != info.SupportedGameVer) + return false; + + return true; + } + + public static bool CanRunDalamud(DirectoryInfo gamePath) + { + using var client = new WebClient(); + + var versionInfoJson = client.DownloadString(REMOTE_BASE + "release"); + var remoteVersionInfo = JsonSerializer.Deserialize(versionInfoJson); + + if (Repository.Ffxiv.GetVer(gamePath) != remoteVersionInfo.SupportedGameVer) + return false; + + return true; + } + } +} diff --git a/src/XIVLauncher2.Common/Dalamud/DalamudLoadMethod.cs b/src/XIVLauncher2.Common/Dalamud/DalamudLoadMethod.cs new file mode 100644 index 0000000..47f3b93 --- /dev/null +++ b/src/XIVLauncher2.Common/Dalamud/DalamudLoadMethod.cs @@ -0,0 +1,14 @@ +namespace XIVLauncher2.Common.Dalamud +{ + public enum DalamudLoadMethod + { + [SettingsDescription("Entrypoint", "dummy")] + EntryPoint, + + [SettingsDescription("DLL Injection", "dummy")] + DllInject, + + [SettingsDescription("ACL-only fix", "dummy")] + ACLonly, + }; +} diff --git a/src/XIVLauncher2.Common/Dalamud/DalamudRunnerException.cs b/src/XIVLauncher2.Common/Dalamud/DalamudRunnerException.cs new file mode 100644 index 0000000..5278a58 --- /dev/null +++ b/src/XIVLauncher2.Common/Dalamud/DalamudRunnerException.cs @@ -0,0 +1,11 @@ +using System; + +namespace XIVLauncher2.Common.Dalamud; + +public class DalamudRunnerException : Exception +{ + public DalamudRunnerException(string message, Exception innerException = null) + : base(message, innerException) + { + } +} diff --git a/src/XIVLauncher2.Common/Dalamud/DalamudSettings.cs b/src/XIVLauncher2.Common/Dalamud/DalamudSettings.cs new file mode 100644 index 0000000..c2e1bfb --- /dev/null +++ b/src/XIVLauncher2.Common/Dalamud/DalamudSettings.cs @@ -0,0 +1,34 @@ +using System; +using System.IO; +using System.Text.Json; +using Serilog; + +namespace XIVLauncher2.Common.Dalamud +{ + public class DalamudSettings + { + public string? DalamudBetaKey { get; set; } = null; + public bool DoDalamudRuntime { get; set; } = false; + public string DalamudBetaKind { get; set; } + + public static string GetConfigPath(DirectoryInfo configFolder) => Path.Combine(configFolder.FullName, "dalamudConfig.json"); + + public static DalamudSettings GetSettings(DirectoryInfo configFolder) + { + var configPath = GetConfigPath(configFolder); + DalamudSettings deserialized = null; + + try + { + deserialized = File.Exists(configPath) ? JsonSerializer.Deserialize(File.ReadAllText(configPath)) : new DalamudSettings(); + } + catch (Exception ex) + { + Log.Error(ex, "Couldn't deserialize Dalamud settings"); + } + + deserialized ??= new DalamudSettings(); // In case the .json is corrupted + return deserialized; + } + } +} diff --git a/src/XIVLauncher2.Common/Dalamud/DalamudStartInfo.cs b/src/XIVLauncher2.Common/Dalamud/DalamudStartInfo.cs new file mode 100644 index 0000000..6f243a3 --- /dev/null +++ b/src/XIVLauncher2.Common/Dalamud/DalamudStartInfo.cs @@ -0,0 +1,20 @@ +using System; + +namespace XIVLauncher2.Common.Dalamud +{ + [Serializable] + public sealed class DalamudStartInfo + { + public string WorkingDirectory; + public string ConfigurationPath; + + public string PluginDirectory; + public string DefaultPluginDirectory; + public string AssetDirectory; + public ClientLanguage Language; + public int DelayInitializeMs; + + public string GameVersion; + public string TroubleshootingPackData; + } +} diff --git a/src/XIVLauncher2.Common/Dalamud/DalamudUpdater.cs b/src/XIVLauncher2.Common/Dalamud/DalamudUpdater.cs new file mode 100644 index 0000000..ac87bf1 --- /dev/null +++ b/src/XIVLauncher2.Common/Dalamud/DalamudUpdater.cs @@ -0,0 +1,501 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Threading.Tasks; +using System.Text.Json; +using Serilog; +using XIVLauncher2.Common.PlatformAbstractions; +using XIVLauncher2.Common.Util; + +namespace XIVLauncher2.Common.Dalamud +{ + public class DalamudUpdater + { + private readonly DirectoryInfo addonDirectory; + private readonly DirectoryInfo runtimeDirectory; + private readonly DirectoryInfo assetDirectory; + private readonly DirectoryInfo configDirectory; + private readonly IUniqueIdCache? cache; + + private readonly TimeSpan defaultTimeout = TimeSpan.FromMinutes(15); + + private bool forceProxy = false; + + public DownloadState State { get; private set; } = DownloadState.Unknown; + public bool IsStaging { get; private set; } = false; + + private FileInfo runnerInternal; + + public FileInfo Runner + { + get + { + if (RunnerOverride != null) + return RunnerOverride; + + return runnerInternal; + } + private set => runnerInternal = value; + } + + public DirectoryInfo Runtime => this.runtimeDirectory; + + public FileInfo RunnerOverride { get; set; } + + public DirectoryInfo AssetDirectory { get; private set; } + + public IDalamudLoadingOverlay Overlay { get; set; } + + public string RolloutBucket { get; set; } + + public enum DownloadState + { + Unknown, + Done, + Failed, + NoIntegrity + } + + public DalamudUpdater(DirectoryInfo addonDirectory, DirectoryInfo runtimeDirectory, DirectoryInfo assetDirectory, DirectoryInfo configDirectory, IUniqueIdCache? cache, string? dalamudRolloutBucket) + { + this.addonDirectory = addonDirectory; + this.runtimeDirectory = runtimeDirectory; + this.assetDirectory = assetDirectory; + this.configDirectory = configDirectory; + this.cache = cache; + + this.RolloutBucket = dalamudRolloutBucket; + + if (this.RolloutBucket == null) + { + var rng = new Random(); + this.RolloutBucket = rng.Next(0, 9) >= 7 ? "Canary" : "Control"; + } + } + + public void SetOverlayProgress(IDalamudLoadingOverlay.DalamudUpdateStep progress) + { + Overlay.SetStep(progress); + } + + public void ShowOverlay() + { + Overlay.SetVisible(); + } + + public void CloseOverlay() + { + Overlay.SetInvisible(); + } + + private void ReportOverlayProgress(long? size, long downloaded, double? progress) + { + Overlay.ReportProgress(size, downloaded, progress); + } + + public void Run() + { + Log.Information("[DUPDATE] Starting..."); + + Task.Run(async () => + { + const int MAX_TRIES = 10; + + for (var tries = 0; tries < MAX_TRIES; tries++) + { + try + { + await UpdateDalamud().ConfigureAwait(true); + break; + } + catch (Exception ex) + { + Log.Error(ex, "[DUPDATE] Update failed, try {TryCnt}/{MaxTries}...", tries, MAX_TRIES); + this.forceProxy = true; + } + } + + if (this.State != DownloadState.Done) this.State = DownloadState.Failed; + }); + } + + private static string GetBetaTrackName(DalamudSettings settings) => + string.IsNullOrEmpty(settings.DalamudBetaKind) ? "staging" : settings.DalamudBetaKind; + + private async Task<(DalamudVersionInfo release, DalamudVersionInfo? staging)> GetVersionInfo(DalamudSettings settings) + { + using var client = new HttpClient + { + Timeout = this.defaultTimeout, + }; + + client.DefaultRequestHeaders.CacheControl = new CacheControlHeaderValue + { + NoCache = true, + }; + + var versionInfoJsonRelease = await client.GetStringAsync(DalamudLauncher.REMOTE_BASE + $"release&bucket={this.RolloutBucket}").ConfigureAwait(false); + + DalamudVersionInfo versionInfoRelease = JsonSerializer.Deserialize(versionInfoJsonRelease); + + DalamudVersionInfo? versionInfoStaging = null; + + if (!string.IsNullOrEmpty(settings.DalamudBetaKey)) + { + var versionInfoJsonStaging = await client.GetAsync(DalamudLauncher.REMOTE_BASE + GetBetaTrackName(settings)).ConfigureAwait(false); + + if (versionInfoJsonStaging.StatusCode != HttpStatusCode.BadRequest) + versionInfoStaging = JsonSerializer.Deserialize(await versionInfoJsonStaging.Content.ReadAsStringAsync().ConfigureAwait(false)); + } + + return (versionInfoRelease, versionInfoStaging); + } + + private async Task UpdateDalamud() + { + var settings = DalamudSettings.GetSettings(this.configDirectory); + + // GitHub requires TLS 1.2, we need to hardcode this for Windows 7 + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12; + + var (versionInfoRelease, versionInfoStaging) = await GetVersionInfo(settings).ConfigureAwait(false); + + var remoteVersionInfo = versionInfoRelease; + + if (versionInfoStaging?.Key != null && versionInfoStaging.Key == settings.DalamudBetaKey) + { + remoteVersionInfo = versionInfoStaging; + IsStaging = true; + Log.Information("[DUPDATE] Using staging version {Kind} with key {Key} ({Hash})", settings.DalamudBetaKind, settings.DalamudBetaKey, remoteVersionInfo.AssemblyVersion); + } + else + { + Log.Information("[DUPDATE] Using release version ({Hash})", remoteVersionInfo.AssemblyVersion); + } + + var versionInfoJson = JsonSerializer.Serialize(remoteVersionInfo); + + var addonPath = new DirectoryInfo(Path.Combine(this.addonDirectory.FullName, "Hooks")); + var currentVersionPath = new DirectoryInfo(Path.Combine(addonPath.FullName, remoteVersionInfo.AssemblyVersion)); + var runtimePaths = new DirectoryInfo[] + { + new(Path.Combine(this.runtimeDirectory.FullName, "host", "fxr", remoteVersionInfo.RuntimeVersion)), + new(Path.Combine(this.runtimeDirectory.FullName, "shared", "Microsoft.NETCore.App", remoteVersionInfo.RuntimeVersion)), + new(Path.Combine(this.runtimeDirectory.FullName, "shared", "Microsoft.WindowsDesktop.App", remoteVersionInfo.RuntimeVersion)), + }; + + if (!currentVersionPath.Exists || !IsIntegrity(currentVersionPath)) + { + Log.Information("[DUPDATE] Not found, redownloading"); + + SetOverlayProgress(IDalamudLoadingOverlay.DalamudUpdateStep.Dalamud); + + try + { + await DownloadDalamud(currentVersionPath, remoteVersionInfo).ConfigureAwait(true); + CleanUpOld(addonPath, remoteVersionInfo.AssemblyVersion); + + // This is a good indicator that we should clear the UID cache + cache?.Reset(); + } + catch (Exception ex) + { + Log.Error(ex, "[DUPDATE] Could not download dalamud"); + + State = DownloadState.NoIntegrity; + return; + } + } + + if (remoteVersionInfo.RuntimeRequired || settings.DoDalamudRuntime) + { + Log.Information("[DUPDATE] Now starting for .NET Runtime {0}", remoteVersionInfo.RuntimeVersion); + + var versionFile = new FileInfo(Path.Combine(this.runtimeDirectory.FullName, "version")); + var localVersion = "5.0.6"; // This is the version we first shipped. We didn't write out a version file, so we can't check it. + if (versionFile.Exists) + localVersion = File.ReadAllText(versionFile.FullName); + + if (!this.runtimeDirectory.Exists) + Directory.CreateDirectory(this.runtimeDirectory.FullName); + + var integrity = await CheckRuntimeHashes(runtimeDirectory, localVersion).ConfigureAwait(false); + + if (runtimePaths.Any(p => !p.Exists) || localVersion != remoteVersionInfo.RuntimeVersion || !integrity) + { + Log.Information("[DUPDATE] Not found, outdated or no integrity: {LocalVer} - {RemoteVer}", localVersion, remoteVersionInfo.RuntimeVersion); + + SetOverlayProgress(IDalamudLoadingOverlay.DalamudUpdateStep.Runtime); + + try + { + await DownloadRuntime(this.runtimeDirectory, remoteVersionInfo.RuntimeVersion).ConfigureAwait(false); + File.WriteAllText(versionFile.FullName, remoteVersionInfo.RuntimeVersion); + } + catch (Exception ex) + { + Log.Error(ex, "[DUPDATE] Could not download runtime"); + + State = DownloadState.Failed; + return; + } + } + } + + try + { + this.SetOverlayProgress(IDalamudLoadingOverlay.DalamudUpdateStep.Assets); + this.ReportOverlayProgress(null, 0, null); + AssetDirectory = await AssetManager.EnsureAssets(this.assetDirectory, this.forceProxy).ConfigureAwait(true); + } + catch (Exception ex) + { + Log.Error(ex, "[DUPDATE] Asset ensurement error, bailing out..."); + State = DownloadState.Failed; + return; + } + + if (!IsIntegrity(currentVersionPath)) + { + Log.Error("[DUPDATE] Integrity check failed after ensurement."); + + State = DownloadState.NoIntegrity; + return; + } + + WriteVersionJson(currentVersionPath, versionInfoJson); + + Log.Information("[DUPDATE] All set for " + remoteVersionInfo.SupportedGameVer); + + Runner = new FileInfo(Path.Combine(currentVersionPath.FullName, "Dalamud.Injector.exe")); + + State = DownloadState.Done; + SetOverlayProgress(IDalamudLoadingOverlay.DalamudUpdateStep.Starting); + } + + private static bool CanRead(FileInfo info) + { + try + { + using var stream = info.OpenRead(); + stream.ReadByte(); + } + catch + { + return false; + } + + return true; + } + + public static bool IsIntegrity(DirectoryInfo addonPath) + { + var files = addonPath.GetFiles(); + + try + { + if (!CanRead(files.First(x => x.Name == "Dalamud.Injector.exe")) + || !CanRead(files.First(x => x.Name == "Dalamud.dll")) + || !CanRead(files.First(x => x.Name == "ImGuiScene.dll"))) + { + Log.Error("[DUPDATE] Can't open files for read"); + return false; + } + + var hashesPath = Path.Combine(addonPath.FullName, "hashes.json"); + + if (!File.Exists(hashesPath)) + { + Log.Error("[DUPDATE] No hashes.json"); + return false; + } + + return CheckIntegrity(addonPath, File.ReadAllText(hashesPath)); + } + catch (Exception ex) + { + Log.Error(ex, "[DUPDATE] No dalamud integrity"); + return false; + } + } + + private static bool CheckIntegrity(DirectoryInfo directory, string hashesJson) + { + try + { + Log.Verbose("[DUPDATE] Checking integrity of {Directory}", directory.FullName); + + var hashes = JsonSerializer.Deserialize>(hashesJson); + + foreach (var hash in hashes) + { + var file = Path.Combine(directory.FullName, hash.Key.Replace("\\", "/")); + using var fileStream = File.OpenRead(file); + using var md5 = MD5.Create(); + + var hashed = BitConverter.ToString(md5.ComputeHash(fileStream)).ToUpperInvariant().Replace("-", string.Empty); + + if (hashed != hash.Value) + { + Log.Error("[DUPDATE] Integrity check failed for {0} ({1} - {2})", file, hash.Value, hashed); + return false; + } + + Log.Verbose("[DUPDATE] Integrity check OK for {0} ({1})", file, hashed); + } + } + catch (Exception ex) + { + Log.Error(ex, "[DUPDATE] Integrity check failed"); + return false; + } + + return true; + } + + private static void CleanUpOld(DirectoryInfo addonPath, string currentVer) + { + if (!addonPath.Exists) + return; + + foreach (var directory in addonPath.GetDirectories()) + { + if (directory.Name == "dev" || directory.Name == currentVer) continue; + + try + { + directory.Delete(true); + } + catch + { + // ignored + } + } + } + + private static void WriteVersionJson(DirectoryInfo addonPath, string info) + { + File.WriteAllText(Path.Combine(addonPath.FullName, "version.json"), info); + } + + private async Task DownloadDalamud(DirectoryInfo addonPath, DalamudVersionInfo version) + { + // Ensure directory exists + if (!addonPath.Exists) + addonPath.Create(); + else + { + addonPath.Delete(true); + addonPath.Create(); + } + + var downloadPath = PlatformHelpers.GetTempFileName(); + + if (File.Exists(downloadPath)) + File.Delete(downloadPath); + + await this.DownloadFile(version.DownloadUrl, downloadPath, this.defaultTimeout).ConfigureAwait(false); + ZipFile.ExtractToDirectory(downloadPath, addonPath.FullName); + + File.Delete(downloadPath); + + try + { + var devPath = new DirectoryInfo(Path.Combine(addonPath.FullName, "..", "dev")); + + if (!devPath.Exists) + devPath.Create(); + else + { + devPath.Delete(true); + devPath.Create(); + } + + foreach (var fileInfo in addonPath.GetFiles()) + { + fileInfo.CopyTo(Path.Combine(devPath.FullName, fileInfo.Name)); + } + } + catch (Exception ex) + { + Log.Error(ex, "[DUPDATE] Could not copy to dev folder."); + } + } + + private async Task CheckRuntimeHashes(DirectoryInfo runtimePath, string version) + { +#if DEBUG + Log.Warning("Debug build, ignoring runtime hash check"); + return true; +#endif + + var hashesFile = new FileInfo(Path.Combine(runtimePath.FullName, $"hashes-{version}.json")); + string? runtimeHashes = null; + + if (!hashesFile.Exists) + { + Log.Verbose("Hashes file does not exist, redownloading..."); + + using var client = new HttpClient(); + runtimeHashes = await client.GetStringAsync($"https://kamori.goats.dev/Dalamud/Release/Runtime/Hashes/{version}").ConfigureAwait(false); + + File.WriteAllText(hashesFile.FullName, runtimeHashes); + } + else + { + runtimeHashes = File.ReadAllText(hashesFile.FullName); + } + + return CheckIntegrity(runtimePath, runtimeHashes); + } + + private async Task DownloadRuntime(DirectoryInfo runtimePath, string version) + { + // Ensure directory exists + if (!runtimePath.Exists) + { + runtimePath.Create(); + } + else + { + runtimePath.Delete(true); + runtimePath.Create(); + } + + var dotnetUrl = $"https://kamori.goats.dev/Dalamud/Release/Runtime/DotNet/{version}"; + var desktopUrl = $"https://kamori.goats.dev/Dalamud/Release/Runtime/WindowsDesktop/{version}"; + + var downloadPath = PlatformHelpers.GetTempFileName(); + + if (File.Exists(downloadPath)) + File.Delete(downloadPath); + + await this.DownloadFile(dotnetUrl, downloadPath, this.defaultTimeout).ConfigureAwait(false); + ZipFile.ExtractToDirectory(downloadPath, runtimePath.FullName); + + await this.DownloadFile(desktopUrl, downloadPath, this.defaultTimeout).ConfigureAwait(false); + ZipFile.ExtractToDirectory(downloadPath, runtimePath.FullName); + + File.Delete(downloadPath); + } + + private async Task DownloadFile(string url, string path, TimeSpan timeout) + { + if (this.forceProxy && url.Contains("/File/Get/")) + { + url = url.Replace("/File/Get/", "/File/GetProxy/"); + } + + using var downloader = new HttpClientDownloadWithProgress(url, path); + downloader.ProgressChanged += this.ReportOverlayProgress; + + await downloader.Download(timeout).ConfigureAwait(false); + } + } +} diff --git a/src/XIVLauncher2.Common/Dalamud/DalamudVersionInfo.cs b/src/XIVLauncher2.Common/Dalamud/DalamudVersionInfo.cs new file mode 100644 index 0000000..456b737 --- /dev/null +++ b/src/XIVLauncher2.Common/Dalamud/DalamudVersionInfo.cs @@ -0,0 +1,30 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace XIVLauncher2.Common.Dalamud +{ + internal class DalamudVersionInfo + { + [JsonPropertyName("assemblyVersion")] + public string AssemblyVersion { get; set; } + + [JsonPropertyName("supportedGameVer")] + public string SupportedGameVer { get; set; } + + [JsonPropertyName("runtimeVersion")] + public string RuntimeVersion { get; set; } + + [JsonPropertyName("runtimeRequired")] + public bool RuntimeRequired { get; set; } + + [JsonPropertyName("key")] + public string Key { get; set; } + + [JsonPropertyName("downloadUrl")] + public string DownloadUrl { get; set; } + + public static DalamudVersionInfo Load(FileInfo file) => + JsonSerializer.Deserialize(File.ReadAllText(file.FullName)); + } +} diff --git a/src/XIVLauncher2.Common/DpiAwareness.cs b/src/XIVLauncher2.Common/DpiAwareness.cs new file mode 100644 index 0000000..7038f00 --- /dev/null +++ b/src/XIVLauncher2.Common/DpiAwareness.cs @@ -0,0 +1,8 @@ +namespace XIVLauncher2.Common +{ + public enum DpiAwareness + { + Aware, + Unaware, + } +} diff --git a/src/XIVLauncher2.Common/Encryption/ArgumentBuilder.cs b/src/XIVLauncher2.Common/Encryption/ArgumentBuilder.cs new file mode 100644 index 0000000..300a5df --- /dev/null +++ b/src/XIVLauncher2.Common/Encryption/ArgumentBuilder.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Linq; +using Serilog; +using System.Runtime.InteropServices; +using XIVLauncher2.Common.Util; + +namespace XIVLauncher2.Common.Encryption +{ + public sealed class ArgumentBuilder + { + private static readonly uint version = 3; + + private static readonly char[] checksumTable = + { + 'f', 'X', '1', 'p', 'G', 't', 'd', 'S', + '5', 'C', 'A', 'P', '4', '_', 'V', 'L' + }; + + private static char DeriveChecksum(uint key) + { + var index = (key & 0x000F_0000) >> 16; + + try + { + return checksumTable[index]; + } + catch (IndexOutOfRangeException) + { + return '!'; // Conceivably, this shouldn't happen... + } + } + + private readonly List> arguments; + + public ArgumentBuilder() + { + this.arguments = new List>(); + } + + public ArgumentBuilder(IEnumerable> items) + { + this.arguments = new List>(items); + } + + public ArgumentBuilder Append(string key, string value) + { + return Append(new KeyValuePair(key, value)); + } + + public ArgumentBuilder Append(KeyValuePair item) + { + this.arguments.Add(item); + + return this; + } + + public ArgumentBuilder Append(IEnumerable> items) + { + this.arguments.AddRange(items); + + return this; + } + + public string Build() + { + return this.arguments.Aggregate(new StringBuilder(), + (whole, part) => whole.Append($" {part.Key}={part.Value}")) + .ToString(); + } + + public string BuildEncrypted(uint key) + { + var arguments = this.arguments.Aggregate(new StringBuilder(), + // Yes, they do have a space prepended even for the first argument. + (whole, part) => whole.Append($" /{EscapeValue(part.Key)} ={EscapeValue(part.Value)}")) + .ToString(); + + var blowfish = new LegacyBlowfish(GetKeyBytes(key)); + var ciphertext = blowfish.Encrypt(Encoding.UTF8.GetBytes(arguments)); + var base64Str = GameHelpers.ToMangledSeBase64(ciphertext); + var checksum = DeriveChecksum(key); + + Log.Information("ArgumentBuilder::BuildEncrypted() checksum:{0}", checksum); + + return $"//**sqex{version:D04}{base64Str}{checksum}**//"; + } + + public string BuildEncrypted() + { + var key = DeriveKey(); + + return BuildEncrypted(key); + } + + private uint DeriveKey() + { + var rawTickCount = (uint)Environment.TickCount; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + [System.Runtime.InteropServices.DllImport("c")] + // ReSharper disable once InconsistentNaming + static extern ulong clock_gettime_nsec_np(int clock_id); + + const int CLOCK_MONOTONIC_RAW = 4; + var rawTickCountFixed = (clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) / 1000000); + Log.Information("ArgumentBuilder::DeriveKey() fixing up rawTickCount from {0} to {1} on macOS", rawTickCount, rawTickCountFixed); + rawTickCount = (uint)rawTickCountFixed; + } + + var ticks = rawTickCount & 0xFFFF_FFFFu; + var key = ticks & 0xFFFF_0000u; + + Log.Information("ArgumentBuilder::DeriveKey() rawTickCount:{0} ticks:{1} key:{2}", rawTickCount, ticks, key); + + var keyPair = new KeyValuePair("T", Convert.ToString(ticks)); + if (this.arguments.Count > 0 && this.arguments[0].Key == "T") + this.arguments[0] = keyPair; + else + this.arguments.Insert(0, keyPair); + + return key; + } + + private static byte[] GetKeyBytes(uint key) + { + var format = $"{key:x08}"; + + return Encoding.UTF8.GetBytes(format); + } + + private static string EscapeValue(string input) + { + return input.Replace(" ", " "); + } + } +} diff --git a/src/XIVLauncher2.Common/Encryption/BlockCipher/Blowfish.cs b/src/XIVLauncher2.Common/Encryption/BlockCipher/Blowfish.cs new file mode 100644 index 0000000..9e94c23 --- /dev/null +++ b/src/XIVLauncher2.Common/Encryption/BlockCipher/Blowfish.cs @@ -0,0 +1,93 @@ +// NOTE: This file is copy-pasted almost *as-is* from the previous work `Aither.Crypto` +// hence currently it does not follow XL's naming convetions. +// +// It's totally okay to change this. But for now, this is what it is atm. +// ReSharper disable InconsistentNaming + +using System; +using System.Buffers.Binary; + +namespace XIVLauncher2.Common.Encryption.BlockCipher +{ + public sealed class Blowfish : IBlockCipher + { + /// + public int BlockSize => 8; + + // NOTE: this field should never be marked as readonly as it actually creates a defensive copy on every access. (it's a trap) + // https://devblogs.microsoft.com/premier-developer/the-in-modifier-and-the-readonly-structs-in-c/ + + private BlowfishState m_state; + + /// + /// Initializes a new instance of the Blowfish class. + /// + /// + /// This function also calculates P-array and S-boxes from the given key. This is most expensive operation in blowfish algorithm. + /// + /// A secret key used for blowfish. Key length must be between 32 and 448 bits. + /// Length of the key is either too short or too long. + public Blowfish(ReadOnlySpan key) + { + m_state = new BlowfishState(key); + } + + public unsafe void EncryptBlockUnsafe(byte* input, byte* output) + { + var inputBlock = (uint*)input; + var outputBlock = (uint*)output; + + var xl = inputBlock[0]; + var xr = inputBlock[1]; + + // will be elided by JIT + if (BitConverter.IsLittleEndian) + { + xl = BinaryPrimitives.ReverseEndianness(xl); + xr = BinaryPrimitives.ReverseEndianness(xr); + } + + (xl, xr) = m_state.EncryptBlock(xl, xr); + + // will be elided by JIT + if (BitConverter.IsLittleEndian) + { + xl = BinaryPrimitives.ReverseEndianness(xl); + xr = BinaryPrimitives.ReverseEndianness(xr); + } + + outputBlock[0] = xl; + outputBlock[1] = xr; + } + + public unsafe void DecryptBlockUnsafe(byte* input, byte* output) + { + var inputBlock = (uint*)input; + var outputBlock = (uint*)output; + + var xl = inputBlock[0]; + var xr = inputBlock[1]; + + // will be elided by JIT + if (BitConverter.IsLittleEndian) + { + xl = BinaryPrimitives.ReverseEndianness(xl); + xr = BinaryPrimitives.ReverseEndianness(xr); + } + + (xl, xr) = m_state.DecryptBlock(xl, xr); + + // will be elided by JIT + if (BitConverter.IsLittleEndian) + { + xl = BinaryPrimitives.ReverseEndianness(xl); + xr = BinaryPrimitives.ReverseEndianness(xr); + } + + outputBlock[0] = xl; + outputBlock[1] = xr; + } + } +} + +// ReSharper restore InconsistentNaming diff --git a/src/XIVLauncher2.Common/Encryption/BlockCipher/BlowfishState.cs b/src/XIVLauncher2.Common/Encryption/BlockCipher/BlowfishState.cs new file mode 100644 index 0000000..c1616c6 --- /dev/null +++ b/src/XIVLauncher2.Common/Encryption/BlockCipher/BlowfishState.cs @@ -0,0 +1,369 @@ +// NOTE: This file is copy-pasted almost *as-is* from the previous work `Aither.Crypto` +// hence currently it does not follow XL's naming convetions. +// +// It's totally okay to change this. But for now, this is what it is atm. +// ReSharper disable InconsistentNaming + +using System; +using System.Runtime.CompilerServices; + +namespace XIVLauncher2.Common.Encryption.BlockCipher +{ + internal unsafe struct BlowfishState + { + // References: + // https://www.schneier.com/academic/archives/1994/09/description_of_a_new.html + // http://www.herongyang.com/Blowfish/Algorithm-Blowfish-Cipher-Encryption-Algorithm.html + // https://en.wikipedia.org/wiki/Blowfish_(cipher) + + private const int Rounds = 16; + public const int PSize = 18; + public const int SSize = 256; + + private static readonly uint[] PInit = new uint[] + { + 0x243F6A88, 0x85A308D3, 0x13198A2E, 0x03707344, 0xA4093822, 0x299F31D0, + 0x082EFA98, 0xEC4E6C89, 0x452821E6, 0x38D01377, 0xBE5466CF, 0x34E90C6C, + 0xC0AC29B7, 0xC97C50DD, 0x3F84D5B5, 0xB5470917, 0x9216D5D9, 0x8979FB1B + }; + + private static readonly uint[] S0Init = new uint[] + { + 0xD1310BA6, 0x98DFB5AC, 0x2FFD72DB, 0xD01ADFB7, 0xB8E1AFED, 0x6A267E96, 0xBA7C9045, 0xF12C7F99, + 0x24A19947, 0xB3916CF7, 0x0801F2E2, 0x858EFC16, 0x636920D8, 0x71574E69, 0xA458FEA3, 0xF4933D7E, + 0x0D95748F, 0x728EB658, 0x718BCD58, 0x82154AEE, 0x7B54A41D, 0xC25A59B5, 0x9C30D539, 0x2AF26013, + 0xC5D1B023, 0x286085F0, 0xCA417918, 0xB8DB38EF, 0x8E79DCB0, 0x603A180E, 0x6C9E0E8B, 0xB01E8A3E, + 0xD71577C1, 0xBD314B27, 0x78AF2FDA, 0x55605C60, 0xE65525F3, 0xAA55AB94, 0x57489862, 0x63E81440, + 0x55CA396A, 0x2AAB10B6, 0xB4CC5C34, 0x1141E8CE, 0xA15486AF, 0x7C72E993, 0xB3EE1411, 0x636FBC2A, + 0x2BA9C55D, 0x741831F6, 0xCE5C3E16, 0x9B87931E, 0xAFD6BA33, 0x6C24CF5C, 0x7A325381, 0x28958677, + 0x3B8F4898, 0x6B4BB9AF, 0xC4BFE81B, 0x66282193, 0x61D809CC, 0xFB21A991, 0x487CAC60, 0x5DEC8032, + 0xEF845D5D, 0xE98575B1, 0xDC262302, 0xEB651B88, 0x23893E81, 0xD396ACC5, 0x0F6D6FF3, 0x83F44239, + 0x2E0B4482, 0xA4842004, 0x69C8F04A, 0x9E1F9B5E, 0x21C66842, 0xF6E96C9A, 0x670C9C61, 0xABD388F0, + 0x6A51A0D2, 0xD8542F68, 0x960FA728, 0xAB5133A3, 0x6EEF0B6C, 0x137A3BE4, 0xBA3BF050, 0x7EFB2A98, + 0xA1F1651D, 0x39AF0176, 0x66CA593E, 0x82430E88, 0x8CEE8619, 0x456F9FB4, 0x7D84A5C3, 0x3B8B5EBE, + 0xE06F75D8, 0x85C12073, 0x401A449F, 0x56C16AA6, 0x4ED3AA62, 0x363F7706, 0x1BFEDF72, 0x429B023D, + 0x37D0D724, 0xD00A1248, 0xDB0FEAD3, 0x49F1C09B, 0x075372C9, 0x80991B7B, 0x25D479D8, 0xF6E8DEF7, + 0xE3FE501A, 0xB6794C3B, 0x976CE0BD, 0x04C006BA, 0xC1A94FB6, 0x409F60C4, 0x5E5C9EC2, 0x196A2463, + 0x68FB6FAF, 0x3E6C53B5, 0x1339B2EB, 0x3B52EC6F, 0x6DFC511F, 0x9B30952C, 0xCC814544, 0xAF5EBD09, + 0xBEE3D004, 0xDE334AFD, 0x660F2807, 0x192E4BB3, 0xC0CBA857, 0x45C8740F, 0xD20B5F39, 0xB9D3FBDB, + 0x5579C0BD, 0x1A60320A, 0xD6A100C6, 0x402C7279, 0x679F25FE, 0xFB1FA3CC, 0x8EA5E9F8, 0xDB3222F8, + 0x3C7516DF, 0xFD616B15, 0x2F501EC8, 0xAD0552AB, 0x323DB5FA, 0xFD238760, 0x53317B48, 0x3E00DF82, + 0x9E5C57BB, 0xCA6F8CA0, 0x1A87562E, 0xDF1769DB, 0xD542A8F6, 0x287EFFC3, 0xAC6732C6, 0x8C4F5573, + 0x695B27B0, 0xBBCA58C8, 0xE1FFA35D, 0xB8F011A0, 0x10FA3D98, 0xFD2183B8, 0x4AFCB56C, 0x2DD1D35B, + 0x9A53E479, 0xB6F84565, 0xD28E49BC, 0x4BFB9790, 0xE1DDF2DA, 0xA4CB7E33, 0x62FB1341, 0xCEE4C6E8, + 0xEF20CADA, 0x36774C01, 0xD07E9EFE, 0x2BF11FB4, 0x95DBDA4D, 0xAE909198, 0xEAAD8E71, 0x6B93D5A0, + 0xD08ED1D0, 0xAFC725E0, 0x8E3C5B2F, 0x8E7594B7, 0x8FF6E2FB, 0xF2122B64, 0x8888B812, 0x900DF01C, + 0x4FAD5EA0, 0x688FC31C, 0xD1CFF191, 0xB3A8C1AD, 0x2F2F2218, 0xBE0E1777, 0xEA752DFE, 0x8B021FA1, + 0xE5A0CC0F, 0xB56F74E8, 0x18ACF3D6, 0xCE89E299, 0xB4A84FE0, 0xFD13E0B7, 0x7CC43B81, 0xD2ADA8D9, + 0x165FA266, 0x80957705, 0x93CC7314, 0x211A1477, 0xE6AD2065, 0x77B5FA86, 0xC75442F5, 0xFB9D35CF, + 0xEBCDAF0C, 0x7B3E89A0, 0xD6411BD3, 0xAE1E7E49, 0x00250E2D, 0x2071B35E, 0x226800BB, 0x57B8E0AF, + 0x2464369B, 0xF009B91E, 0x5563911D, 0x59DFA6AA, 0x78C14389, 0xD95A537F, 0x207D5BA2, 0x02E5B9C5, + 0x83260376, 0x6295CFA9, 0x11C81968, 0x4E734A41, 0xB3472DCA, 0x7B14A94A, 0x1B510052, 0x9A532915, + 0xD60F573F, 0xBC9BC6E4, 0x2B60A476, 0x81E67400, 0x08BA6FB5, 0x571BE91F, 0xF296EC6B, 0x2A0DD915, + 0xB6636521, 0xE7B9F9B6, 0xFF34052E, 0xC5855664, 0x53B02D5D, 0xA99F8FA1, 0x08BA4799, 0x6E85076A + }; + + private static readonly uint[] S1Init = new uint[] + { + 0x4B7A70E9, 0xB5B32944, 0xDB75092E, 0xC4192623, 0xAD6EA6B0, 0x49A7DF7D, 0x9CEE60B8, 0x8FEDB266, + 0xECAA8C71, 0x699A17FF, 0x5664526C, 0xC2B19EE1, 0x193602A5, 0x75094C29, 0xA0591340, 0xE4183A3E, + 0x3F54989A, 0x5B429D65, 0x6B8FE4D6, 0x99F73FD6, 0xA1D29C07, 0xEFE830F5, 0x4D2D38E6, 0xF0255DC1, + 0x4CDD2086, 0x8470EB26, 0x6382E9C6, 0x021ECC5E, 0x09686B3F, 0x3EBAEFC9, 0x3C971814, 0x6B6A70A1, + 0x687F3584, 0x52A0E286, 0xB79C5305, 0xAA500737, 0x3E07841C, 0x7FDEAE5C, 0x8E7D44EC, 0x5716F2B8, + 0xB03ADA37, 0xF0500C0D, 0xF01C1F04, 0x0200B3FF, 0xAE0CF51A, 0x3CB574B2, 0x25837A58, 0xDC0921BD, + 0xD19113F9, 0x7CA92FF6, 0x94324773, 0x22F54701, 0x3AE5E581, 0x37C2DADC, 0xC8B57634, 0x9AF3DDA7, + 0xA9446146, 0x0FD0030E, 0xECC8C73E, 0xA4751E41, 0xE238CD99, 0x3BEA0E2F, 0x3280BBA1, 0x183EB331, + 0x4E548B38, 0x4F6DB908, 0x6F420D03, 0xF60A04BF, 0x2CB81290, 0x24977C79, 0x5679B072, 0xBCAF89AF, + 0xDE9A771F, 0xD9930810, 0xB38BAE12, 0xDCCF3F2E, 0x5512721F, 0x2E6B7124, 0x501ADDE6, 0x9F84CD87, + 0x7A584718, 0x7408DA17, 0xBC9F9ABC, 0xE94B7D8C, 0xEC7AEC3A, 0xDB851DFA, 0x63094366, 0xC464C3D2, + 0xEF1C1847, 0x3215D908, 0xDD433B37, 0x24C2BA16, 0x12A14D43, 0x2A65C451, 0x50940002, 0x133AE4DD, + 0x71DFF89E, 0x10314E55, 0x81AC77D6, 0x5F11199B, 0x043556F1, 0xD7A3C76B, 0x3C11183B, 0x5924A509, + 0xF28FE6ED, 0x97F1FBFA, 0x9EBABF2C, 0x1E153C6E, 0x86E34570, 0xEAE96FB1, 0x860E5E0A, 0x5A3E2AB3, + 0x771FE71C, 0x4E3D06FA, 0x2965DCB9, 0x99E71D0F, 0x803E89D6, 0x5266C825, 0x2E4CC978, 0x9C10B36A, + 0xC6150EBA, 0x94E2EA78, 0xA5FC3C53, 0x1E0A2DF4, 0xF2F74EA7, 0x361D2B3D, 0x1939260F, 0x19C27960, + 0x5223A708, 0xF71312B6, 0xEBADFE6E, 0xEAC31F66, 0xE3BC4595, 0xA67BC883, 0xB17F37D1, 0x018CFF28, + 0xC332DDEF, 0xBE6C5AA5, 0x65582185, 0x68AB9802, 0xEECEA50F, 0xDB2F953B, 0x2AEF7DAD, 0x5B6E2F84, + 0x1521B628, 0x29076170, 0xECDD4775, 0x619F1510, 0x13CCA830, 0xEB61BD96, 0x0334FE1E, 0xAA0363CF, + 0xB5735C90, 0x4C70A239, 0xD59E9E0B, 0xCBAADE14, 0xEECC86BC, 0x60622CA7, 0x9CAB5CAB, 0xB2F3846E, + 0x648B1EAF, 0x19BDF0CA, 0xA02369B9, 0x655ABB50, 0x40685A32, 0x3C2AB4B3, 0x319EE9D5, 0xC021B8F7, + 0x9B540B19, 0x875FA099, 0x95F7997E, 0x623D7DA8, 0xF837889A, 0x97E32D77, 0x11ED935F, 0x16681281, + 0x0E358829, 0xC7E61FD6, 0x96DEDFA1, 0x7858BA99, 0x57F584A5, 0x1B227263, 0x9B83C3FF, 0x1AC24696, + 0xCDB30AEB, 0x532E3054, 0x8FD948E4, 0x6DBC3128, 0x58EBF2EF, 0x34C6FFEA, 0xFE28ED61, 0xEE7C3C73, + 0x5D4A14D9, 0xE864B7E3, 0x42105D14, 0x203E13E0, 0x45EEE2B6, 0xA3AAABEA, 0xDB6C4F15, 0xFACB4FD0, + 0xC742F442, 0xEF6ABBB5, 0x654F3B1D, 0x41CD2105, 0xD81E799E, 0x86854DC7, 0xE44B476A, 0x3D816250, + 0xCF62A1F2, 0x5B8D2646, 0xFC8883A0, 0xC1C7B6A3, 0x7F1524C3, 0x69CB7492, 0x47848A0B, 0x5692B285, + 0x095BBF00, 0xAD19489D, 0x1462B174, 0x23820E00, 0x58428D2A, 0x0C55F5EA, 0x1DADF43E, 0x233F7061, + 0x3372F092, 0x8D937E41, 0xD65FECF1, 0x6C223BDB, 0x7CDE3759, 0xCBEE7460, 0x4085F2A7, 0xCE77326E, + 0xA6078084, 0x19F8509E, 0xE8EFD855, 0x61D99735, 0xA969A7AA, 0xC50C06C2, 0x5A04ABFC, 0x800BCADC, + 0x9E447A2E, 0xC3453484, 0xFDD56705, 0x0E1E9EC9, 0xDB73DBD3, 0x105588CD, 0x675FDA79, 0xE3674340, + 0xC5C43465, 0x713E38D8, 0x3D28F89E, 0xF16DFF20, 0x153E21E7, 0x8FB03D4A, 0xE6E39F2B, 0xDB83ADF7 + }; + + private static readonly uint[] S2Init = new uint[] + { + 0xE93D5A68, 0x948140F7, 0xF64C261C, 0x94692934, 0x411520F7, 0x7602D4F7, 0xBCF46B2E, 0xD4A20068, + 0xD4082471, 0x3320F46A, 0x43B7D4B7, 0x500061AF, 0x1E39F62E, 0x97244546, 0x14214F74, 0xBF8B8840, + 0x4D95FC1D, 0x96B591AF, 0x70F4DDD3, 0x66A02F45, 0xBFBC09EC, 0x03BD9785, 0x7FAC6DD0, 0x31CB8504, + 0x96EB27B3, 0x55FD3941, 0xDA2547E6, 0xABCA0A9A, 0x28507825, 0x530429F4, 0x0A2C86DA, 0xE9B66DFB, + 0x68DC1462, 0xD7486900, 0x680EC0A4, 0x27A18DEE, 0x4F3FFEA2, 0xE887AD8C, 0xB58CE006, 0x7AF4D6B6, + 0xAACE1E7C, 0xD3375FEC, 0xCE78A399, 0x406B2A42, 0x20FE9E35, 0xD9F385B9, 0xEE39D7AB, 0x3B124E8B, + 0x1DC9FAF7, 0x4B6D1856, 0x26A36631, 0xEAE397B2, 0x3A6EFA74, 0xDD5B4332, 0x6841E7F7, 0xCA7820FB, + 0xFB0AF54E, 0xD8FEB397, 0x454056AC, 0xBA489527, 0x55533A3A, 0x20838D87, 0xFE6BA9B7, 0xD096954B, + 0x55A867BC, 0xA1159A58, 0xCCA92963, 0x99E1DB33, 0xA62A4A56, 0x3F3125F9, 0x5EF47E1C, 0x9029317C, + 0xFDF8E802, 0x04272F70, 0x80BB155C, 0x05282CE3, 0x95C11548, 0xE4C66D22, 0x48C1133F, 0xC70F86DC, + 0x07F9C9EE, 0x41041F0F, 0x404779A4, 0x5D886E17, 0x325F51EB, 0xD59BC0D1, 0xF2BCC18F, 0x41113564, + 0x257B7834, 0x602A9C60, 0xDFF8E8A3, 0x1F636C1B, 0x0E12B4C2, 0x02E1329E, 0xAF664FD1, 0xCAD18115, + 0x6B2395E0, 0x333E92E1, 0x3B240B62, 0xEEBEB922, 0x85B2A20E, 0xE6BA0D99, 0xDE720C8C, 0x2DA2F728, + 0xD0127845, 0x95B794FD, 0x647D0862, 0xE7CCF5F0, 0x5449A36F, 0x877D48FA, 0xC39DFD27, 0xF33E8D1E, + 0x0A476341, 0x992EFF74, 0x3A6F6EAB, 0xF4F8FD37, 0xA812DC60, 0xA1EBDDF8, 0x991BE14C, 0xDB6E6B0D, + 0xC67B5510, 0x6D672C37, 0x2765D43B, 0xDCD0E804, 0xF1290DC7, 0xCC00FFA3, 0xB5390F92, 0x690FED0B, + 0x667B9FFB, 0xCEDB7D9C, 0xA091CF0B, 0xD9155EA3, 0xBB132F88, 0x515BAD24, 0x7B9479BF, 0x763BD6EB, + 0x37392EB3, 0xCC115979, 0x8026E297, 0xF42E312D, 0x6842ADA7, 0xC66A2B3B, 0x12754CCC, 0x782EF11C, + 0x6A124237, 0xB79251E7, 0x06A1BBE6, 0x4BFB6350, 0x1A6B1018, 0x11CAEDFA, 0x3D25BDD8, 0xE2E1C3C9, + 0x44421659, 0x0A121386, 0xD90CEC6E, 0xD5ABEA2A, 0x64AF674E, 0xDA86A85F, 0xBEBFE988, 0x64E4C3FE, + 0x9DBC8057, 0xF0F7C086, 0x60787BF8, 0x6003604D, 0xD1FD8346, 0xF6381FB0, 0x7745AE04, 0xD736FCCC, + 0x83426B33, 0xF01EAB71, 0xB0804187, 0x3C005E5F, 0x77A057BE, 0xBDE8AE24, 0x55464299, 0xBF582E61, + 0x4E58F48F, 0xF2DDFDA2, 0xF474EF38, 0x8789BDC2, 0x5366F9C3, 0xC8B38E74, 0xB475F255, 0x46FCD9B9, + 0x7AEB2661, 0x8B1DDF84, 0x846A0E79, 0x915F95E2, 0x466E598E, 0x20B45770, 0x8CD55591, 0xC902DE4C, + 0xB90BACE1, 0xBB8205D0, 0x11A86248, 0x7574A99E, 0xB77F19B6, 0xE0A9DC09, 0x662D09A1, 0xC4324633, + 0xE85A1F02, 0x09F0BE8C, 0x4A99A025, 0x1D6EFE10, 0x1AB93D1D, 0x0BA5A4DF, 0xA186F20F, 0x2868F169, + 0xDCB7DA83, 0x573906FE, 0xA1E2CE9B, 0x4FCD7F52, 0x50115E01, 0xA70683FA, 0xA002B5C4, 0x0DE6D027, + 0x9AF88C27, 0x773F8641, 0xC3604C06, 0x61A806B5, 0xF0177A28, 0xC0F586E0, 0x006058AA, 0x30DC7D62, + 0x11E69ED7, 0x2338EA63, 0x53C2DD94, 0xC2C21634, 0xBBCBEE56, 0x90BCB6DE, 0xEBFC7DA1, 0xCE591D76, + 0x6F05E409, 0x4B7C0188, 0x39720A3D, 0x7C927C24, 0x86E3725F, 0x724D9DB9, 0x1AC15BB4, 0xD39EB8FC, + 0xED545578, 0x08FCA5B5, 0xD83D7CD3, 0x4DAD0FC4, 0x1E50EF5E, 0xB161E6F8, 0xA28514D9, 0x6C51133C, + 0x6FD5C7E7, 0x56E14EC4, 0x362ABFCE, 0xDDC6C837, 0xD79A3234, 0x92638212, 0x670EFA8E, 0x406000E0 + }; + + private static readonly uint[] S3Init = new uint[] + { + 0x3A39CE37, 0xD3FAF5CF, 0xABC27737, 0x5AC52D1B, 0x5CB0679E, 0x4FA33742, 0xD3822740, 0x99BC9BBE, + 0xD5118E9D, 0xBF0F7315, 0xD62D1C7E, 0xC700C47B, 0xB78C1B6B, 0x21A19045, 0xB26EB1BE, 0x6A366EB4, + 0x5748AB2F, 0xBC946E79, 0xC6A376D2, 0x6549C2C8, 0x530FF8EE, 0x468DDE7D, 0xD5730A1D, 0x4CD04DC6, + 0x2939BBDB, 0xA9BA4650, 0xAC9526E8, 0xBE5EE304, 0xA1FAD5F0, 0x6A2D519A, 0x63EF8CE2, 0x9A86EE22, + 0xC089C2B8, 0x43242EF6, 0xA51E03AA, 0x9CF2D0A4, 0x83C061BA, 0x9BE96A4D, 0x8FE51550, 0xBA645BD6, + 0x2826A2F9, 0xA73A3AE1, 0x4BA99586, 0xEF5562E9, 0xC72FEFD3, 0xF752F7DA, 0x3F046F69, 0x77FA0A59, + 0x80E4A915, 0x87B08601, 0x9B09E6AD, 0x3B3EE593, 0xE990FD5A, 0x9E34D797, 0x2CF0B7D9, 0x022B8B51, + 0x96D5AC3A, 0x017DA67D, 0xD1CF3ED6, 0x7C7D2D28, 0x1F9F25CF, 0xADF2B89B, 0x5AD6B472, 0x5A88F54C, + 0xE029AC71, 0xE019A5E6, 0x47B0ACFD, 0xED93FA9B, 0xE8D3C48D, 0x283B57CC, 0xF8D56629, 0x79132E28, + 0x785F0191, 0xED756055, 0xF7960E44, 0xE3D35E8C, 0x15056DD4, 0x88F46DBA, 0x03A16125, 0x0564F0BD, + 0xC3EB9E15, 0x3C9057A2, 0x97271AEC, 0xA93A072A, 0x1B3F6D9B, 0x1E6321F5, 0xF59C66FB, 0x26DCF319, + 0x7533D928, 0xB155FDF5, 0x03563482, 0x8ABA3CBB, 0x28517711, 0xC20AD9F8, 0xABCC5167, 0xCCAD925F, + 0x4DE81751, 0x3830DC8E, 0x379D5862, 0x9320F991, 0xEA7A90C2, 0xFB3E7BCE, 0x5121CE64, 0x774FBE32, + 0xA8B6E37E, 0xC3293D46, 0x48DE5369, 0x6413E680, 0xA2AE0810, 0xDD6DB224, 0x69852DFD, 0x09072166, + 0xB39A460A, 0x6445C0DD, 0x586CDECF, 0x1C20C8AE, 0x5BBEF7DD, 0x1B588D40, 0xCCD2017F, 0x6BB4E3BB, + 0xDDA26A7E, 0x3A59FF45, 0x3E350A44, 0xBCB4CDD5, 0x72EACEA8, 0xFA6484BB, 0x8D6612AE, 0xBF3C6F47, + 0xD29BE463, 0x542F5D9E, 0xAEC2771B, 0xF64E6370, 0x740E0D8D, 0xE75B1357, 0xF8721671, 0xAF537D5D, + 0x4040CB08, 0x4EB4E2CC, 0x34D2466A, 0x0115AF84, 0xE1B00428, 0x95983A1D, 0x06B89FB4, 0xCE6EA048, + 0x6F3F3B82, 0x3520AB82, 0x011A1D4B, 0x277227F8, 0x611560B1, 0xE7933FDC, 0xBB3A792B, 0x344525BD, + 0xA08839E1, 0x51CE794B, 0x2F32C9B7, 0xA01FBAC9, 0xE01CC87E, 0xBCC7D1F6, 0xCF0111C3, 0xA1E8AAC7, + 0x1A908749, 0xD44FBD9A, 0xD0DADECB, 0xD50ADA38, 0x0339C32A, 0xC6913667, 0x8DF9317C, 0xE0B12B4F, + 0xF79E59B7, 0x43F5BB3A, 0xF2D519FF, 0x27D9459C, 0xBF97222C, 0x15E6FC2A, 0x0F91FC71, 0x9B941525, + 0xFAE59361, 0xCEB69CEB, 0xC2A86459, 0x12BAA8D1, 0xB6C1075E, 0xE3056A0C, 0x10D25065, 0xCB03A442, + 0xE0EC6E0E, 0x1698DB3B, 0x4C98A0BE, 0x3278E964, 0x9F1F9532, 0xE0D392DF, 0xD3A0342B, 0x8971F21E, + 0x1B0A7441, 0x4BA3348C, 0xC5BE7120, 0xC37632D8, 0xDF359F8D, 0x9B992F2E, 0xE60B6F47, 0x0FE3F11D, + 0xE54CDA54, 0x1EDAD891, 0xCE6279CF, 0xCD3E7E6F, 0x1618B166, 0xFD2C1D05, 0x848FD2C5, 0xF6FB2299, + 0xF523F357, 0xA6327623, 0x93A83531, 0x56CCCD02, 0xACF08162, 0x5A75EBB5, 0x6E163697, 0x88D273CC, + 0xDE966292, 0x81B949D0, 0x4C50901B, 0x71C65614, 0xE6C6C7BD, 0x327A140A, 0x45E1D006, 0xC3F27B9A, + 0xC9AA53FD, 0x62A80F00, 0xBB25BFE2, 0x35BDD2F6, 0x71126905, 0xB2040222, 0xB6CBCF7C, 0xCD769C2B, + 0x53113EC0, 0x1640E3D3, 0x38ABBD60, 0x2547ADF0, 0xBA38209C, 0xF746CE76, 0x77AFA1C5, 0x20756060, + 0x85CBFE4E, 0x8AE88DD8, 0x7AAAF9B0, 0x4CF9AA7E, 0x1948C25C, 0x02FB8A8C, 0x01C36AE4, 0xD6EBE1F9, + 0x90D4F869, 0xA65CDEA0, 0x3F09252D, 0xC208E69F, 0xB74E6132, 0xCE77E25B, 0x578FDFE3, 0x3AC372E6 + }; + + private fixed uint m_p[PSize]; + private fixed uint m_s0[SSize]; + private fixed uint m_s1[SSize]; + private fixed uint m_s2[SSize]; + private fixed uint m_s3[SSize]; + + public BlowfishState(ReadOnlySpan key) + { + CheckKeyLength(key); + + // initializes P-array and S-boxes to initial values. + fixed (uint* pSrc = PInit) + fixed (uint* pDst = m_p) + { + Buffer.MemoryCopy(pSrc, pDst, PSize * 4, PSize * 4); + } + + fixed (uint* pSrc = S0Init) + fixed (uint* pDst = m_s0) + { + Buffer.MemoryCopy(pSrc, pDst, SSize * 4, SSize * 4); + } + + fixed (uint* pSrc = S1Init) + fixed (uint* pDst = m_s1) + { + Buffer.MemoryCopy(pSrc, pDst, SSize * 4, SSize * 4); + } + + fixed (uint* pSrc = S2Init) + fixed (uint* pDst = m_s2) + { + Buffer.MemoryCopy(pSrc, pDst, SSize * 4, SSize * 4); + } + + fixed (uint* pSrc = S3Init) + fixed (uint* pDst = m_s3) + { + Buffer.MemoryCopy(pSrc, pDst, SSize * 4, SSize * 4); + } + + InitKey(key); + } + + private void CheckKeyLength(ReadOnlySpan key) + { + // Supported key sizes: 32–448 bits + // https://en.wikipedia.org/wiki/Blowfish_(cipher)#The_algorithm + if (key.Length < 4 || key.Length > 56) + { + throw new ArgumentException("Key length must be between from 32 to 448 bits.", nameof(key)); + } + } + + /// + /// Encrypts a block. + /// + /// A left side of the block. + /// A right side of the block. + public (uint, uint) EncryptBlock(uint xl, uint xr) + { + // https://en.wikipedia.org/wiki/Feistel_cipher#Construction_details + for (var i = 0; i < Rounds; i += 2) + { + xl ^= m_p[i]; + xr ^= Round(xl); + xr ^= m_p[i + 1]; + xl ^= Round(xr); + } + + xl ^= m_p[16]; + xr ^= m_p[17]; + + // swap(L, R) + var temp = xl; + xl = xr; + xr = temp; + + return (xl, xr); + } + + /// + /// Decrypts a block. + /// + /// A left side of the block. + /// A right side of the blick. + public (uint, uint) DecryptBlock(uint xl, uint xr) + { + // https://en.wikipedia.org/wiki/Feistel_cipher#Construction_details + for (var i = Rounds; i > 0; i -= 2) + { + xl ^= m_p[i + 1]; + xr ^= Round(xr); + xr ^= m_p[i]; + xr ^= Round(xr); + } + + xl ^= m_p[1]; + xr ^= m_p[0]; + + // swap(L, R); + var temp = xl; + xl = xr; + xr = temp; + + return (xl, xr); + } + + /// + /// Initializes P-array and S-boxes with given key. P-array and S-boxes must be initialized to initial values beforehand. + /// + /// A setup key. + private void InitKey(ReadOnlySpan key) + { + { + var keyPos = 0; + + for (var i = 0; i < PSize; i++) + { + var val = 0u; + + // wrapping u32 (be) + // eg. key = { 12 34 56 78 AB CD EF GH HI JK } + // => {0x12345678, 0xABCDEFGH, 0xHIJK1234, 0x5678ABCD, ..} + for (var j = 0; j < 4; j++) + { + // wrap to the start when we reached the end. + if (keyPos >= key.Length) + { + keyPos = 0; + } + + val = (val << 8) | key[keyPos++]; + } + + m_p[i] ^= val; + } + } + + { + var xl = 0u; + var xr = 0u; + + for (var i = 0; i < PSize; i += 2) + { + (xl, xr) = EncryptBlock(xl, xr); + + m_p[i] = xl; + m_p[i + 1] = xr; + } + + for (var i = 0; i < SSize; i += 2) + { + (xl, xr) = EncryptBlock(xl, xr); + + m_s0[i] = xl; + m_s0[i + 1] = xr; + } + + for (var i = 0; i < SSize; i += 2) + { + (xl, xr) = EncryptBlock(xl, xr); + + m_s1[i] = xl; + m_s1[i + 1] = xr; + } + + for (var i = 0; i < SSize; i += 2) + { + (xl, xr) = EncryptBlock(xl, xr); + + m_s2[i] = xl; + m_s2[i + 1] = xr; + } + + for (var i = 0; i < SSize; i += 2) + { + (xl, xr) = EncryptBlock(xl, xr); + + m_s3[i] = xl; + m_s3[i + 1] = xr; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining /* | MethodImplOptions.AggressiveOptimization */)] + private uint Round(uint x) + { + return unchecked( + ((m_s0[x >> 24] + m_s1[(byte)(x >> 16)]) ^ m_s2[(byte)(x >> 8)]) + m_s3[(byte)x] + ); + } + } +} + +// ReSharper restore InconsistentNaming diff --git a/src/XIVLauncher2.Common/Encryption/BlockCipher/Ecb.cs b/src/XIVLauncher2.Common/Encryption/BlockCipher/Ecb.cs new file mode 100644 index 0000000..3fcd1ab --- /dev/null +++ b/src/XIVLauncher2.Common/Encryption/BlockCipher/Ecb.cs @@ -0,0 +1,72 @@ +// NOTE: This file is copy-pasted almost *as-is* from the previous work `Aither.Crypto` +// hence currently it does not follow XL's naming convetions. +// +// It's totally okay to change this. But for now, this is what it is atm. +// ReSharper disable InconsistentNaming + +using System; + +namespace XIVLauncher2.Common.Encryption.BlockCipher +{ + public sealed class Ecb : IBlockMode where T : IBlockCipher + { + private T m_cipher; + + public Ecb(T cipher) + { + m_cipher = cipher; + } + + private void AssertSliceLength(ReadOnlySpan input, ReadOnlySpan output) + { + if (input.Length > output.Length) + { + throw new ArgumentException("The output slice must be larger than the input.", nameof(output)); + } + + var blockSize = m_cipher.BlockSize; + + if (input.Length % blockSize != 0) + { + throw new ArgumentException("The length of the input slice must be divisible by the block length.", + nameof(input)); + } + } + + public void Encrypt(ReadOnlySpan input, Span output) + { + AssertSliceLength(input, output); + + unsafe + { + fixed (byte* pInput = input) + fixed (byte* pOutput = output) + { + for (var i = 0; i < input.Length; i += m_cipher.BlockSize) + { + m_cipher.EncryptBlockUnsafe(pInput + i, pOutput + i); + } + } + } + } + + public void Decrypt(ReadOnlySpan input, Span output) + { + AssertSliceLength(input, output); + + unsafe + { + fixed (byte* pInput = input) + fixed (byte* pOutput = output) + { + for (var i = 0; i < input.Length; i += m_cipher.BlockSize) + { + m_cipher.DecryptBlockUnsafe(pInput + i, pOutput + i); + } + } + } + } + } +} + +// ReSharper restore InconsistentNaming diff --git a/src/XIVLauncher2.Common/Encryption/BlockCipher/IBlockCipher.cs b/src/XIVLauncher2.Common/Encryption/BlockCipher/IBlockCipher.cs new file mode 100644 index 0000000..308e95e --- /dev/null +++ b/src/XIVLauncher2.Common/Encryption/BlockCipher/IBlockCipher.cs @@ -0,0 +1,54 @@ +// NOTE: This file is copy-pasted almost *as-is* from the previous work `Aither.Crypto` +// hence currently it does not follow XL's naming convetions. +// +// It's totally okay to change this. But for now, this is what it is atm. +// ReSharper disable InconsistentNaming + +namespace XIVLauncher2.Common.Encryption.BlockCipher +{ + public interface IBlockCipher + { + /// + /// A number of bytes that can be processed in a single operation. + /// + /// + /// This property is assumed to be immutable once the block cipher object is created. + /// Breaking this assumption may cause an undefined behavior. + /// + int BlockSize { get; } + + /// + /// Encrypts a single block. + /// + /// + /// A pointer to the data needs to be encrypted. + /// It must be valid to read bytes from the pointer where size is indicated by BlockSize property. + /// + /// + /// A pointer to the buffer to store the result of the operation. + /// It must be valid to write bytes to the pointer where size is indicated by BlockSize property. + /// + /// + /// A pointer to input and output **can** overlap to perform in-place operation. + /// + unsafe void EncryptBlockUnsafe(byte* input, byte* output); + + /// + /// Decrypts a single block. + /// + /// + /// A pointer to the data needs to be decrypted. + /// It must be valid to read bytes from the pointer where size is indicated by BlockSize property. + /// + /// + /// A pointer to the buffer to store the result of the operation. + /// It must be valid to write bytes to the pointer where size is indicated by BlockSize property. + /// + /// + /// A pointer to input and output **can** overlap to perform in-place operation. + /// + unsafe void DecryptBlockUnsafe(byte* input, byte* output); + } +} + +// ReSharper restore InconsistentNaming diff --git a/src/XIVLauncher2.Common/Encryption/BlockCipher/IBlockMode.cs b/src/XIVLauncher2.Common/Encryption/BlockCipher/IBlockMode.cs new file mode 100644 index 0000000..b995c34 --- /dev/null +++ b/src/XIVLauncher2.Common/Encryption/BlockCipher/IBlockMode.cs @@ -0,0 +1,18 @@ +// NOTE: This file is copy-pasted almost *as-is* from the previous work `Aither.Crypto` +// hence currently it does not follow XL's naming convetions. +// +// It's totally okay to change this. But for now, this is what it is atm. +// ReSharper disable InconsistentNaming + +using System; + +namespace XIVLauncher2.Common.Encryption.BlockCipher +{ + public interface IBlockMode + { + void Encrypt(ReadOnlySpan input, Span output); + void Decrypt(ReadOnlySpan input, Span output); + } +} + +// ReSharper restore InconsistentNaming diff --git a/src/XIVLauncher2.Common/Encryption/CrtRand.cs b/src/XIVLauncher2.Common/Encryption/CrtRand.cs new file mode 100644 index 0000000..7053378 --- /dev/null +++ b/src/XIVLauncher2.Common/Encryption/CrtRand.cs @@ -0,0 +1,17 @@ +namespace XIVLauncher2.Common.Encryption; + +public class CrtRand +{ + private uint seed; + + public CrtRand(uint seed) + { + this.seed = seed; + } + + public uint Next() + { + this.seed = 0x343FD * this.seed + 0x269EC3; + return ((this.seed >> 16) & 0xFFFF) & 0x7FFF; + } +} diff --git a/src/XIVLauncher2.Common/Encryption/LegacyBlowfish.cs b/src/XIVLauncher2.Common/Encryption/LegacyBlowfish.cs new file mode 100644 index 0000000..c5926a2 --- /dev/null +++ b/src/XIVLauncher2.Common/Encryption/LegacyBlowfish.cs @@ -0,0 +1,316 @@ +using System; +using System.Collections.Generic; + +namespace XIVLauncher2.Common.Encryption +{ + public class LegacyBlowfish + { + #region P-Array and S-Boxes + + private readonly uint[] p = + { + 0x243f6a88, 0x85a308d3, 0x13198a2e, 0x03707344, 0xa4093822, 0x299f31d0, + 0x082efa98, 0xec4e6c89, 0x452821e6, 0x38d01377, 0xbe5466cf, 0x34e90c6c, + 0xc0ac29b7, 0xc97c50dd, 0x3f84d5b5, 0xb5470917, 0x9216d5d9, 0x8979fb1b + }; + + private readonly uint[,] s = + { + { + 0xd1310ba6, 0x98dfb5ac, 0x2ffd72db, 0xd01adfb7, 0xb8e1afed, 0x6a267e96, + 0xba7c9045, 0xf12c7f99, 0x24a19947, 0xb3916cf7, 0x0801f2e2, 0x858efc16, + 0x636920d8, 0x71574e69, 0xa458fea3, 0xf4933d7e, 0x0d95748f, 0x728eb658, + 0x718bcd58, 0x82154aee, 0x7b54a41d, 0xc25a59b5, 0x9c30d539, 0x2af26013, + 0xc5d1b023, 0x286085f0, 0xca417918, 0xb8db38ef, 0x8e79dcb0, 0x603a180e, + 0x6c9e0e8b, 0xb01e8a3e, 0xd71577c1, 0xbd314b27, 0x78af2fda, 0x55605c60, + 0xe65525f3, 0xaa55ab94, 0x57489862, 0x63e81440, 0x55ca396a, 0x2aab10b6, + 0xb4cc5c34, 0x1141e8ce, 0xa15486af, 0x7c72e993, 0xb3ee1411, 0x636fbc2a, + 0x2ba9c55d, 0x741831f6, 0xce5c3e16, 0x9b87931e, 0xafd6ba33, 0x6c24cf5c, + 0x7a325381, 0x28958677, 0x3b8f4898, 0x6b4bb9af, 0xc4bfe81b, 0x66282193, + 0x61d809cc, 0xfb21a991, 0x487cac60, 0x5dec8032, 0xef845d5d, 0xe98575b1, + 0xdc262302, 0xeb651b88, 0x23893e81, 0xd396acc5, 0x0f6d6ff3, 0x83f44239, + 0x2e0b4482, 0xa4842004, 0x69c8f04a, 0x9e1f9b5e, 0x21c66842, 0xf6e96c9a, + 0x670c9c61, 0xabd388f0, 0x6a51a0d2, 0xd8542f68, 0x960fa728, 0xab5133a3, + 0x6eef0b6c, 0x137a3be4, 0xba3bf050, 0x7efb2a98, 0xa1f1651d, 0x39af0176, + 0x66ca593e, 0x82430e88, 0x8cee8619, 0x456f9fb4, 0x7d84a5c3, 0x3b8b5ebe, + 0xe06f75d8, 0x85c12073, 0x401a449f, 0x56c16aa6, 0x4ed3aa62, 0x363f7706, + 0x1bfedf72, 0x429b023d, 0x37d0d724, 0xd00a1248, 0xdb0fead3, 0x49f1c09b, + 0x075372c9, 0x80991b7b, 0x25d479d8, 0xf6e8def7, 0xe3fe501a, 0xb6794c3b, + 0x976ce0bd, 0x04c006ba, 0xc1a94fb6, 0x409f60c4, 0x5e5c9ec2, 0x196a2463, + 0x68fb6faf, 0x3e6c53b5, 0x1339b2eb, 0x3b52ec6f, 0x6dfc511f, 0x9b30952c, + 0xcc814544, 0xaf5ebd09, 0xbee3d004, 0xde334afd, 0x660f2807, 0x192e4bb3, + 0xc0cba857, 0x45c8740f, 0xd20b5f39, 0xb9d3fbdb, 0x5579c0bd, 0x1a60320a, + 0xd6a100c6, 0x402c7279, 0x679f25fe, 0xfb1fa3cc, 0x8ea5e9f8, 0xdb3222f8, + 0x3c7516df, 0xfd616b15, 0x2f501ec8, 0xad0552ab, 0x323db5fa, 0xfd238760, + 0x53317b48, 0x3e00df82, 0x9e5c57bb, 0xca6f8ca0, 0x1a87562e, 0xdf1769db, + 0xd542a8f6, 0x287effc3, 0xac6732c6, 0x8c4f5573, 0x695b27b0, 0xbbca58c8, + 0xe1ffa35d, 0xb8f011a0, 0x10fa3d98, 0xfd2183b8, 0x4afcb56c, 0x2dd1d35b, + 0x9a53e479, 0xb6f84565, 0xd28e49bc, 0x4bfb9790, 0xe1ddf2da, 0xa4cb7e33, + 0x62fb1341, 0xcee4c6e8, 0xef20cada, 0x36774c01, 0xd07e9efe, 0x2bf11fb4, + 0x95dbda4d, 0xae909198, 0xeaad8e71, 0x6b93d5a0, 0xd08ed1d0, 0xafc725e0, + 0x8e3c5b2f, 0x8e7594b7, 0x8ff6e2fb, 0xf2122b64, 0x8888b812, 0x900df01c, + 0x4fad5ea0, 0x688fc31c, 0xd1cff191, 0xb3a8c1ad, 0x2f2f2218, 0xbe0e1777, + 0xea752dfe, 0x8b021fa1, 0xe5a0cc0f, 0xb56f74e8, 0x18acf3d6, 0xce89e299, + 0xb4a84fe0, 0xfd13e0b7, 0x7cc43b81, 0xd2ada8d9, 0x165fa266, 0x80957705, + 0x93cc7314, 0x211a1477, 0xe6ad2065, 0x77b5fa86, 0xc75442f5, 0xfb9d35cf, + 0xebcdaf0c, 0x7b3e89a0, 0xd6411bd3, 0xae1e7e49, 0x00250e2d, 0x2071b35e, + 0x226800bb, 0x57b8e0af, 0x2464369b, 0xf009b91e, 0x5563911d, 0x59dfa6aa, + 0x78c14389, 0xd95a537f, 0x207d5ba2, 0x02e5b9c5, 0x83260376, 0x6295cfa9, + 0x11c81968, 0x4e734a41, 0xb3472dca, 0x7b14a94a, 0x1b510052, 0x9a532915, + 0xd60f573f, 0xbc9bc6e4, 0x2b60a476, 0x81e67400, 0x08ba6fb5, 0x571be91f, + 0xf296ec6b, 0x2a0dd915, 0xb6636521, 0xe7b9f9b6, 0xff34052e, 0xc5855664, + 0x53b02d5d, 0xa99f8fa1, 0x08ba4799, 0x6e85076a + }, + { + 0x4b7a70e9, 0xb5b32944, 0xdb75092e, 0xc4192623, 0xad6ea6b0, 0x49a7df7d, + 0x9cee60b8, 0x8fedb266, 0xecaa8c71, 0x699a17ff, 0x5664526c, 0xc2b19ee1, + 0x193602a5, 0x75094c29, 0xa0591340, 0xe4183a3e, 0x3f54989a, 0x5b429d65, + 0x6b8fe4d6, 0x99f73fd6, 0xa1d29c07, 0xefe830f5, 0x4d2d38e6, 0xf0255dc1, + 0x4cdd2086, 0x8470eb26, 0x6382e9c6, 0x021ecc5e, 0x09686b3f, 0x3ebaefc9, + 0x3c971814, 0x6b6a70a1, 0x687f3584, 0x52a0e286, 0xb79c5305, 0xaa500737, + 0x3e07841c, 0x7fdeae5c, 0x8e7d44ec, 0x5716f2b8, 0xb03ada37, 0xf0500c0d, + 0xf01c1f04, 0x0200b3ff, 0xae0cf51a, 0x3cb574b2, 0x25837a58, 0xdc0921bd, + 0xd19113f9, 0x7ca92ff6, 0x94324773, 0x22f54701, 0x3ae5e581, 0x37c2dadc, + 0xc8b57634, 0x9af3dda7, 0xa9446146, 0x0fd0030e, 0xecc8c73e, 0xa4751e41, + 0xe238cd99, 0x3bea0e2f, 0x3280bba1, 0x183eb331, 0x4e548b38, 0x4f6db908, + 0x6f420d03, 0xf60a04bf, 0x2cb81290, 0x24977c79, 0x5679b072, 0xbcaf89af, + 0xde9a771f, 0xd9930810, 0xb38bae12, 0xdccf3f2e, 0x5512721f, 0x2e6b7124, + 0x501adde6, 0x9f84cd87, 0x7a584718, 0x7408da17, 0xbc9f9abc, 0xe94b7d8c, + 0xec7aec3a, 0xdb851dfa, 0x63094366, 0xc464c3d2, 0xef1c1847, 0x3215d908, + 0xdd433b37, 0x24c2ba16, 0x12a14d43, 0x2a65c451, 0x50940002, 0x133ae4dd, + 0x71dff89e, 0x10314e55, 0x81ac77d6, 0x5f11199b, 0x043556f1, 0xd7a3c76b, + 0x3c11183b, 0x5924a509, 0xf28fe6ed, 0x97f1fbfa, 0x9ebabf2c, 0x1e153c6e, + 0x86e34570, 0xeae96fb1, 0x860e5e0a, 0x5a3e2ab3, 0x771fe71c, 0x4e3d06fa, + 0x2965dcb9, 0x99e71d0f, 0x803e89d6, 0x5266c825, 0x2e4cc978, 0x9c10b36a, + 0xc6150eba, 0x94e2ea78, 0xa5fc3c53, 0x1e0a2df4, 0xf2f74ea7, 0x361d2b3d, + 0x1939260f, 0x19c27960, 0x5223a708, 0xf71312b6, 0xebadfe6e, 0xeac31f66, + 0xe3bc4595, 0xa67bc883, 0xb17f37d1, 0x018cff28, 0xc332ddef, 0xbe6c5aa5, + 0x65582185, 0x68ab9802, 0xeecea50f, 0xdb2f953b, 0x2aef7dad, 0x5b6e2f84, + 0x1521b628, 0x29076170, 0xecdd4775, 0x619f1510, 0x13cca830, 0xeb61bd96, + 0x0334fe1e, 0xaa0363cf, 0xb5735c90, 0x4c70a239, 0xd59e9e0b, 0xcbaade14, + 0xeecc86bc, 0x60622ca7, 0x9cab5cab, 0xb2f3846e, 0x648b1eaf, 0x19bdf0ca, + 0xa02369b9, 0x655abb50, 0x40685a32, 0x3c2ab4b3, 0x319ee9d5, 0xc021b8f7, + 0x9b540b19, 0x875fa099, 0x95f7997e, 0x623d7da8, 0xf837889a, 0x97e32d77, + 0x11ed935f, 0x16681281, 0x0e358829, 0xc7e61fd6, 0x96dedfa1, 0x7858ba99, + 0x57f584a5, 0x1b227263, 0x9b83c3ff, 0x1ac24696, 0xcdb30aeb, 0x532e3054, + 0x8fd948e4, 0x6dbc3128, 0x58ebf2ef, 0x34c6ffea, 0xfe28ed61, 0xee7c3c73, + 0x5d4a14d9, 0xe864b7e3, 0x42105d14, 0x203e13e0, 0x45eee2b6, 0xa3aaabea, + 0xdb6c4f15, 0xfacb4fd0, 0xc742f442, 0xef6abbb5, 0x654f3b1d, 0x41cd2105, + 0xd81e799e, 0x86854dc7, 0xe44b476a, 0x3d816250, 0xcf62a1f2, 0x5b8d2646, + 0xfc8883a0, 0xc1c7b6a3, 0x7f1524c3, 0x69cb7492, 0x47848a0b, 0x5692b285, + 0x095bbf00, 0xad19489d, 0x1462b174, 0x23820e00, 0x58428d2a, 0x0c55f5ea, + 0x1dadf43e, 0x233f7061, 0x3372f092, 0x8d937e41, 0xd65fecf1, 0x6c223bdb, + 0x7cde3759, 0xcbee7460, 0x4085f2a7, 0xce77326e, 0xa6078084, 0x19f8509e, + 0xe8efd855, 0x61d99735, 0xa969a7aa, 0xc50c06c2, 0x5a04abfc, 0x800bcadc, + 0x9e447a2e, 0xc3453484, 0xfdd56705, 0x0e1e9ec9, 0xdb73dbd3, 0x105588cd, + 0x675fda79, 0xe3674340, 0xc5c43465, 0x713e38d8, 0x3d28f89e, 0xf16dff20, + 0x153e21e7, 0x8fb03d4a, 0xe6e39f2b, 0xdb83adf7 + }, + { + 0xe93d5a68, 0x948140f7, 0xf64c261c, 0x94692934, 0x411520f7, 0x7602d4f7, + 0xbcf46b2e, 0xd4a20068, 0xd4082471, 0x3320f46a, 0x43b7d4b7, 0x500061af, + 0x1e39f62e, 0x97244546, 0x14214f74, 0xbf8b8840, 0x4d95fc1d, 0x96b591af, + 0x70f4ddd3, 0x66a02f45, 0xbfbc09ec, 0x03bd9785, 0x7fac6dd0, 0x31cb8504, + 0x96eb27b3, 0x55fd3941, 0xda2547e6, 0xabca0a9a, 0x28507825, 0x530429f4, + 0x0a2c86da, 0xe9b66dfb, 0x68dc1462, 0xd7486900, 0x680ec0a4, 0x27a18dee, + 0x4f3ffea2, 0xe887ad8c, 0xb58ce006, 0x7af4d6b6, 0xaace1e7c, 0xd3375fec, + 0xce78a399, 0x406b2a42, 0x20fe9e35, 0xd9f385b9, 0xee39d7ab, 0x3b124e8b, + 0x1dc9faf7, 0x4b6d1856, 0x26a36631, 0xeae397b2, 0x3a6efa74, 0xdd5b4332, + 0x6841e7f7, 0xca7820fb, 0xfb0af54e, 0xd8feb397, 0x454056ac, 0xba489527, + 0x55533a3a, 0x20838d87, 0xfe6ba9b7, 0xd096954b, 0x55a867bc, 0xa1159a58, + 0xcca92963, 0x99e1db33, 0xa62a4a56, 0x3f3125f9, 0x5ef47e1c, 0x9029317c, + 0xfdf8e802, 0x04272f70, 0x80bb155c, 0x05282ce3, 0x95c11548, 0xe4c66d22, + 0x48c1133f, 0xc70f86dc, 0x07f9c9ee, 0x41041f0f, 0x404779a4, 0x5d886e17, + 0x325f51eb, 0xd59bc0d1, 0xf2bcc18f, 0x41113564, 0x257b7834, 0x602a9c60, + 0xdff8e8a3, 0x1f636c1b, 0x0e12b4c2, 0x02e1329e, 0xaf664fd1, 0xcad18115, + 0x6b2395e0, 0x333e92e1, 0x3b240b62, 0xeebeb922, 0x85b2a20e, 0xe6ba0d99, + 0xde720c8c, 0x2da2f728, 0xd0127845, 0x95b794fd, 0x647d0862, 0xe7ccf5f0, + 0x5449a36f, 0x877d48fa, 0xc39dfd27, 0xf33e8d1e, 0x0a476341, 0x992eff74, + 0x3a6f6eab, 0xf4f8fd37, 0xa812dc60, 0xa1ebddf8, 0x991be14c, 0xdb6e6b0d, + 0xc67b5510, 0x6d672c37, 0x2765d43b, 0xdcd0e804, 0xf1290dc7, 0xcc00ffa3, + 0xb5390f92, 0x690fed0b, 0x667b9ffb, 0xcedb7d9c, 0xa091cf0b, 0xd9155ea3, + 0xbb132f88, 0x515bad24, 0x7b9479bf, 0x763bd6eb, 0x37392eb3, 0xcc115979, + 0x8026e297, 0xf42e312d, 0x6842ada7, 0xc66a2b3b, 0x12754ccc, 0x782ef11c, + 0x6a124237, 0xb79251e7, 0x06a1bbe6, 0x4bfb6350, 0x1a6b1018, 0x11caedfa, + 0x3d25bdd8, 0xe2e1c3c9, 0x44421659, 0x0a121386, 0xd90cec6e, 0xd5abea2a, + 0x64af674e, 0xda86a85f, 0xbebfe988, 0x64e4c3fe, 0x9dbc8057, 0xf0f7c086, + 0x60787bf8, 0x6003604d, 0xd1fd8346, 0xf6381fb0, 0x7745ae04, 0xd736fccc, + 0x83426b33, 0xf01eab71, 0xb0804187, 0x3c005e5f, 0x77a057be, 0xbde8ae24, + 0x55464299, 0xbf582e61, 0x4e58f48f, 0xf2ddfda2, 0xf474ef38, 0x8789bdc2, + 0x5366f9c3, 0xc8b38e74, 0xb475f255, 0x46fcd9b9, 0x7aeb2661, 0x8b1ddf84, + 0x846a0e79, 0x915f95e2, 0x466e598e, 0x20b45770, 0x8cd55591, 0xc902de4c, + 0xb90bace1, 0xbb8205d0, 0x11a86248, 0x7574a99e, 0xb77f19b6, 0xe0a9dc09, + 0x662d09a1, 0xc4324633, 0xe85a1f02, 0x09f0be8c, 0x4a99a025, 0x1d6efe10, + 0x1ab93d1d, 0x0ba5a4df, 0xa186f20f, 0x2868f169, 0xdcb7da83, 0x573906fe, + 0xa1e2ce9b, 0x4fcd7f52, 0x50115e01, 0xa70683fa, 0xa002b5c4, 0x0de6d027, + 0x9af88c27, 0x773f8641, 0xc3604c06, 0x61a806b5, 0xf0177a28, 0xc0f586e0, + 0x006058aa, 0x30dc7d62, 0x11e69ed7, 0x2338ea63, 0x53c2dd94, 0xc2c21634, + 0xbbcbee56, 0x90bcb6de, 0xebfc7da1, 0xce591d76, 0x6f05e409, 0x4b7c0188, + 0x39720a3d, 0x7c927c24, 0x86e3725f, 0x724d9db9, 0x1ac15bb4, 0xd39eb8fc, + 0xed545578, 0x08fca5b5, 0xd83d7cd3, 0x4dad0fc4, 0x1e50ef5e, 0xb161e6f8, + 0xa28514d9, 0x6c51133c, 0x6fd5c7e7, 0x56e14ec4, 0x362abfce, 0xddc6c837, + 0xd79a3234, 0x92638212, 0x670efa8e, 0x406000e0 + }, + { + 0x3a39ce37, 0xd3faf5cf, 0xabc27737, 0x5ac52d1b, 0x5cb0679e, 0x4fa33742, + 0xd3822740, 0x99bc9bbe, 0xd5118e9d, 0xbf0f7315, 0xd62d1c7e, 0xc700c47b, + 0xb78c1b6b, 0x21a19045, 0xb26eb1be, 0x6a366eb4, 0x5748ab2f, 0xbc946e79, + 0xc6a376d2, 0x6549c2c8, 0x530ff8ee, 0x468dde7d, 0xd5730a1d, 0x4cd04dc6, + 0x2939bbdb, 0xa9ba4650, 0xac9526e8, 0xbe5ee304, 0xa1fad5f0, 0x6a2d519a, + 0x63ef8ce2, 0x9a86ee22, 0xc089c2b8, 0x43242ef6, 0xa51e03aa, 0x9cf2d0a4, + 0x83c061ba, 0x9be96a4d, 0x8fe51550, 0xba645bd6, 0x2826a2f9, 0xa73a3ae1, + 0x4ba99586, 0xef5562e9, 0xc72fefd3, 0xf752f7da, 0x3f046f69, 0x77fa0a59, + 0x80e4a915, 0x87b08601, 0x9b09e6ad, 0x3b3ee593, 0xe990fd5a, 0x9e34d797, + 0x2cf0b7d9, 0x022b8b51, 0x96d5ac3a, 0x017da67d, 0xd1cf3ed6, 0x7c7d2d28, + 0x1f9f25cf, 0xadf2b89b, 0x5ad6b472, 0x5a88f54c, 0xe029ac71, 0xe019a5e6, + 0x47b0acfd, 0xed93fa9b, 0xe8d3c48d, 0x283b57cc, 0xf8d56629, 0x79132e28, + 0x785f0191, 0xed756055, 0xf7960e44, 0xe3d35e8c, 0x15056dd4, 0x88f46dba, + 0x03a16125, 0x0564f0bd, 0xc3eb9e15, 0x3c9057a2, 0x97271aec, 0xa93a072a, + 0x1b3f6d9b, 0x1e6321f5, 0xf59c66fb, 0x26dcf319, 0x7533d928, 0xb155fdf5, + 0x03563482, 0x8aba3cbb, 0x28517711, 0xc20ad9f8, 0xabcc5167, 0xccad925f, + 0x4de81751, 0x3830dc8e, 0x379d5862, 0x9320f991, 0xea7a90c2, 0xfb3e7bce, + 0x5121ce64, 0x774fbe32, 0xa8b6e37e, 0xc3293d46, 0x48de5369, 0x6413e680, + 0xa2ae0810, 0xdd6db224, 0x69852dfd, 0x09072166, 0xb39a460a, 0x6445c0dd, + 0x586cdecf, 0x1c20c8ae, 0x5bbef7dd, 0x1b588d40, 0xccd2017f, 0x6bb4e3bb, + 0xdda26a7e, 0x3a59ff45, 0x3e350a44, 0xbcb4cdd5, 0x72eacea8, 0xfa6484bb, + 0x8d6612ae, 0xbf3c6f47, 0xd29be463, 0x542f5d9e, 0xaec2771b, 0xf64e6370, + 0x740e0d8d, 0xe75b1357, 0xf8721671, 0xaf537d5d, 0x4040cb08, 0x4eb4e2cc, + 0x34d2466a, 0x0115af84, 0xe1b00428, 0x95983a1d, 0x06b89fb4, 0xce6ea048, + 0x6f3f3b82, 0x3520ab82, 0x011a1d4b, 0x277227f8, 0x611560b1, 0xe7933fdc, + 0xbb3a792b, 0x344525bd, 0xa08839e1, 0x51ce794b, 0x2f32c9b7, 0xa01fbac9, + 0xe01cc87e, 0xbcc7d1f6, 0xcf0111c3, 0xa1e8aac7, 0x1a908749, 0xd44fbd9a, + 0xd0dadecb, 0xd50ada38, 0x0339c32a, 0xc6913667, 0x8df9317c, 0xe0b12b4f, + 0xf79e59b7, 0x43f5bb3a, 0xf2d519ff, 0x27d9459c, 0xbf97222c, 0x15e6fc2a, + 0x0f91fc71, 0x9b941525, 0xfae59361, 0xceb69ceb, 0xc2a86459, 0x12baa8d1, + 0xb6c1075e, 0xe3056a0c, 0x10d25065, 0xcb03a442, 0xe0ec6e0e, 0x1698db3b, + 0x4c98a0be, 0x3278e964, 0x9f1f9532, 0xe0d392df, 0xd3a0342b, 0x8971f21e, + 0x1b0a7441, 0x4ba3348c, 0xc5be7120, 0xc37632d8, 0xdf359f8d, 0x9b992f2e, + 0xe60b6f47, 0x0fe3f11d, 0xe54cda54, 0x1edad891, 0xce6279cf, 0xcd3e7e6f, + 0x1618b166, 0xfd2c1d05, 0x848fd2c5, 0xf6fb2299, 0xf523f357, 0xa6327623, + 0x93a83531, 0x56cccd02, 0xacf08162, 0x5a75ebb5, 0x6e163697, 0x88d273cc, + 0xde966292, 0x81b949d0, 0x4c50901b, 0x71c65614, 0xe6c6c7bd, 0x327a140a, + 0x45e1d006, 0xc3f27b9a, 0xc9aa53fd, 0x62a80f00, 0xbb25bfe2, 0x35bdd2f6, + 0x71126905, 0xb2040222, 0xb6cbcf7c, 0xcd769c2b, 0x53113ec0, 0x1640e3d3, + 0x38abbd60, 0x2547adf0, 0xba38209c, 0xf746ce76, 0x77afa1c5, 0x20756060, + 0x85cbfe4e, 0x8ae88dd8, 0x7aaaf9b0, 0x4cf9aa7e, 0x1948c25c, 0x02fb8a8c, + 0x01c36ae4, 0xd6ebe1f9, 0x90d4f869, 0xa65cdea0, 0x3f09252d, 0xc208e69f, + 0xb74e6132, 0xce77e25b, 0x578fdfe3, 0x3ac372e6 + } + }; + + #endregion + + private static readonly int Rounds = 16; + + /// + /// Initialize a new blowfish. + /// + /// The key to use. + /// Whether or not a sign confusion should be introduced during key init. This is needed for SE's implementation of blowfish. + public LegacyBlowfish(byte[] key) + { + foreach (var (i, keyFragment) in WrappingUInt32(key, this.p.Length)) + this.p[i] ^= keyFragment; + + uint l = 0, r = 0; + for (int i = 0; i < this.p.Length; i += 2) + (l, r) = (this.p[i], this.p[i + 1]) = Encrypt(l, r); + + for (int i = 0; i < this.s.GetLength(0); i++) + for (int j = 0; j < this.s.GetLength(1); j += 2) + (l, r) = (this.s[i, j], this.s[i, j + 1]) = Encrypt(l, r); + } + + public byte[] Encrypt(byte[] data) + { + var paddedLength = data.Length % 8 == 0 ? data.Length : data.Length + (8 - (data.Length % 8)); + var buffer = new byte[paddedLength]; + Buffer.BlockCopy(data, 0, buffer, 0, data.Length); + + for (int i = 0; i < paddedLength; i += 8) + { + var (l, r) = Encrypt(BitConverter.ToUInt32(buffer, i), BitConverter.ToUInt32(buffer, i + 4)); + CopyUInt32IntoArray(buffer, l, i); + CopyUInt32IntoArray(buffer, r, i + 4); + } + + return buffer; + } + + public void Decrypt(ref byte[] data) + { + for (int i = 0; i < data.Length; i += 8) + { + var (l, r) = Decrypt(BitConverter.ToUInt32(data, i), BitConverter.ToUInt32(data, i + 4)); + CopyUInt32IntoArray(data, l, i); + CopyUInt32IntoArray(data, r, i + 4); + } + } + + private static void CopyUInt32IntoArray(byte[] dest, uint val, int offset) + { + dest[offset] = (byte)(val & 0xFF); + dest[offset + 1] = (byte)((val >> 8) & 0xFF); + dest[offset + 2] = (byte)((val >> 16) & 0xFF); + dest[offset + 3] = (byte)((val >> 24) & 0xFF); + } + + private uint F(uint i) + { + return ((this.s[0, i >> 24] + + this.s[1, (i >> 16) & 0xFF]) + ^ this.s[2, (i >> 8) & 0xFF]) + + this.s[3, i & 0xFF]; + } + + private (uint, uint) Encrypt(uint l, uint r) + { + for (int i = 0; i < Rounds; i += 2) + { + l ^= this.p[i]; + r ^= F(l); + r ^= this.p[i + 1]; + l ^= F(r); + } + + return (r ^ this.p[17], l ^ this.p[16]); + } + + private (uint, uint) Decrypt(uint l, uint r) + { + for (int i = Rounds; i > 0; i -= 2) + { + l ^= this.p[i + 1]; + r ^= F(l); + r ^= this.p[i]; + l ^= F(r); + } + + return (r ^ this.p[0], l ^ this.p[1]); + } + + private static IEnumerable Cycle(IEnumerable source) + { + while (true) + foreach (TSource t in source) + yield return t; + } + + private IEnumerable<(int, uint)> WrappingUInt32(IEnumerable source, int count) + { + var enumerator = Cycle(source).GetEnumerator(); + + for (int i = 0; i < count; i++) + { + var n = 0u; + + for (var j = 0; j < 4 && enumerator.MoveNext(); j++) + { + n = (uint)((n << 8) | (sbyte)enumerator.Current); // NOTE(goat): THIS IS A BUG! SE's implementation wrongly uses signed numbers for this, so we need to as well. + } + + yield return (i, n); + } + } + } +} diff --git a/src/XIVLauncher2.Common/Encryption/Ticket.cs b/src/XIVLauncher2.Common/Encryption/Ticket.cs new file mode 100644 index 0000000..8d5b367 --- /dev/null +++ b/src/XIVLauncher2.Common/Encryption/Ticket.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Serilog; +using XIVLauncher2.Common.Encryption.BlockCipher; +using XIVLauncher2.Common.PlatformAbstractions; +using XIVLauncher2.Common.Util; + +namespace XIVLauncher2.Common.Encryption; + +public class Ticket +{ + public string Text { get; } + public int Length { get; } + + private const string FUCKED_GARBAGE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_"; + + private Ticket(string text, int length) + { + this.Text = text; + this.Length = length; + } + + public static async Task Get(ISteam steam) + { + var ticketBytes = await steam.GetAuthSessionTicketAsync().ConfigureAwait(true); + + if (ticketBytes == null) + return null; + + return EncryptAuthSessionTicket(ticketBytes, steam.GetServerRealTime()); + } + + public static Ticket EncryptAuthSessionTicket(byte[] ticket, uint time) + { + time -= 5; + time -= time % 60; // Time should be rounded to nearest minute. + + var ticketString = BitConverter.ToString(ticket).Replace("-", "").ToLower(); + var rawTicketBytes = Encoding.ASCII.GetBytes(ticketString); + + var rawTicket = new byte[rawTicketBytes.Length + 1]; + Array.Copy(rawTicketBytes, rawTicket, rawTicketBytes.Length); + rawTicket[rawTicket.Length - 1] = 0; + + var blowfishKey = $"{time:x08}#un@e=x>"; + + using var memorySteam = new MemoryStream(); + using var binaryWriter = new BinaryWriter(memorySteam); + + /* REGULAR SUM + TICKET */ + ushort ticketSum = 0; + + foreach (byte b in rawTicket) + { + ticketSum += b; + } + + binaryWriter.Write(ticketSum); + binaryWriter.Write(rawTicket); + + /* GARBAGE */ + int castTicketSum = unchecked((short)ticketSum); + var seed = time ^ castTicketSum; + var rand = new CrtRand((uint)seed); + + var numRandomBytes = ((ulong)(rawTicket.Length + 9) & 0xFFFFFFFFFFFFFFF8) - 2 - (ulong)rawTicket.Length; + var garbage = new byte[numRandomBytes]; + + uint fuckedSum = BitConverter.ToUInt32(memorySteam.ToArray(), 0); + + for (var i = 0u; i < numRandomBytes; i++) + { + var randChar = FUCKED_GARBAGE_ALPHABET[(int)(fuckedSum + rand.Next()) & 0x3F]; + garbage[i] = (byte)randChar; + fuckedSum += randChar; + } + + binaryWriter.Write(garbage); + + memorySteam.Seek(0, SeekOrigin.Begin); + binaryWriter.Write(fuckedSum); + + Log.Verbose("[STEAM] time: {Time}, bfKey: {FishKey}, rawTicket.Length: {TicketLen}, ticketSum: {TicketSum}, fuckedSum: {FuckedSum}, seed: {Seed}, numRandomBytes: {NumRandomBytes}", time, + blowfishKey, rawTicket.Length, ticketSum, fuckedSum, seed, numRandomBytes); + + /* ENC + SPLIT */ + var finalBytes = memorySteam.ToArray(); + + var t = finalBytes[0]; + finalBytes[0] = finalBytes[1]; + finalBytes[1] = t; + + var keyBytes = Encoding.ASCII.GetBytes(blowfishKey); + + var blowfish = new Blowfish(keyBytes); + var ecb = new Ecb(blowfish); + + var encBytes = new byte[finalBytes.Length]; + Debug.Assert(encBytes.Length % 8 == 0); + + ecb.Encrypt(finalBytes, encBytes); + var encString = GameHelpers.ToMangledSeBase64(encBytes); + + const int SPLIT_SIZE = 300; + var parts = ChunksUpto(encString, SPLIT_SIZE).ToArray(); + + var finalString = string.Join(",", parts); + + return new Ticket(finalString, finalString.Length - (parts.Length - 1)); + } + + private static IEnumerable ChunksUpto(string str, int maxChunkSize) + { + for (var i = 0; i < str.Length; i += maxChunkSize) + yield return str.Substring(i, Math.Min(maxChunkSize, str.Length - i)); + } +} diff --git a/src/XIVLauncher2.Common/EnvironmentSettings.cs b/src/XIVLauncher2.Common/EnvironmentSettings.cs new file mode 100644 index 0000000..2114bc8 --- /dev/null +++ b/src/XIVLauncher2.Common/EnvironmentSettings.cs @@ -0,0 +1,14 @@ +namespace XIVLauncher2.Common +{ + public static class EnvironmentSettings + { + public static bool IsWine => CheckEnvBool("XL_WINEONLINUX"); + public static bool IsHardwareRendered => CheckEnvBool("XL_HWRENDER"); + public static bool IsDisableUpdates => CheckEnvBool("XL_NOAUTOUPDATE"); + public static bool IsPreRelease => CheckEnvBool("XL_PRERELEASE"); + public static bool IsNoRunas => CheckEnvBool("XL_NO_RUNAS"); + public static bool IsIgnoreSpaceRequirements => CheckEnvBool("XL_NO_SPACE_REQUIREMENTS"); + public static bool IsOpenSteamMinimal => CheckEnvBool("XL_OPEN_STEAM_MINIMAL"); + private static bool CheckEnvBool(string var) => bool.Parse(System.Environment.GetEnvironmentVariable(var) ?? "false"); + } +} diff --git a/src/XIVLauncher2.Common/ExistingProcess.cs b/src/XIVLauncher2.Common/ExistingProcess.cs new file mode 100644 index 0000000..61dbaca --- /dev/null +++ b/src/XIVLauncher2.Common/ExistingProcess.cs @@ -0,0 +1,28 @@ +using System; +using System.Diagnostics; +using System.Reflection; +using Microsoft.Win32.SafeHandles; + +namespace XIVLauncher2.Common; + +/// +/// Class allowing the creation of a Process object based on an already held handle. +/// +public class ExistingProcess : Process +{ + public ExistingProcess(IntPtr handle) + { + SetHandle(handle); + } + + private void SetHandle(IntPtr handle) + { + var baseType = GetType().BaseType; + if (baseType == null) + return; + + var setProcessHandleMethod = baseType.GetMethod("SetProcessHandle", + BindingFlags.NonPublic | BindingFlags.Instance); + setProcessHandleMethod?.Invoke(this, new object[] { new SafeProcessHandle(handle, true) }); + } +} diff --git a/src/XIVLauncher2.Common/Game/Exceptions/BinaryNotPresentException.cs b/src/XIVLauncher2.Common/Game/Exceptions/BinaryNotPresentException.cs new file mode 100644 index 0000000..48c09b6 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Exceptions/BinaryNotPresentException.cs @@ -0,0 +1,14 @@ +using System; + +namespace XIVLauncher2.Common.Game.Exceptions; + +public class BinaryNotPresentException : Exception +{ + public string Path { get; private set; } + + public BinaryNotPresentException(string path) + : base("Game binary was not found") + { + this.Path = path; + } +} diff --git a/src/XIVLauncher2.Common/Game/Exceptions/GameExitedException.cs b/src/XIVLauncher2.Common/Game/Exceptions/GameExitedException.cs new file mode 100644 index 0000000..3311613 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Exceptions/GameExitedException.cs @@ -0,0 +1,11 @@ +using System; + +namespace XIVLauncher2.Common.Game.Exceptions; + +public class GameExitedException : Exception +{ + public GameExitedException() + : base("Game exited prematurely.") + { + } +} diff --git a/src/XIVLauncher2.Common/Game/Exceptions/InvalidResponseException.cs b/src/XIVLauncher2.Common/Game/Exceptions/InvalidResponseException.cs new file mode 100644 index 0000000..9a2d754 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Exceptions/InvalidResponseException.cs @@ -0,0 +1,14 @@ +using System; + +namespace XIVLauncher2.Common.Game.Exceptions; + +public class InvalidResponseException : Exception +{ + public string Document { get; set; } + + public InvalidResponseException(string message, string document) + : base(message) + { + this.Document = document; + } +} diff --git a/src/XIVLauncher2.Common/Game/Exceptions/InvalidVersionFilesException.cs b/src/XIVLauncher2.Common/Game/Exceptions/InvalidVersionFilesException.cs new file mode 100644 index 0000000..37dadb0 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Exceptions/InvalidVersionFilesException.cs @@ -0,0 +1,11 @@ +using System; + +namespace XIVLauncher2.Common.Game.Exceptions; + +public class InvalidVersionFilesException : Exception +{ + public InvalidVersionFilesException() + : base("Version files are invalid.") + { + } +} diff --git a/src/XIVLauncher2.Common/Game/Exceptions/NoVersionReferenceException.cs b/src/XIVLauncher2.Common/Game/Exceptions/NoVersionReferenceException.cs new file mode 100644 index 0000000..e1dc011 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Exceptions/NoVersionReferenceException.cs @@ -0,0 +1,11 @@ +using System; + +namespace XIVLauncher2.Common.Game.Exceptions; + +public class NoVersionReferenceException : Exception +{ + public NoVersionReferenceException(Repository repo, string version) + : base($"No version reference found for {repo}({version})") + { + } +} diff --git a/src/XIVLauncher2.Common/Game/Exceptions/OauthLoginException.cs b/src/XIVLauncher2.Common/Game/Exceptions/OauthLoginException.cs new file mode 100644 index 0000000..90fbed6 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Exceptions/OauthLoginException.cs @@ -0,0 +1,33 @@ +using System; +using System.Text.RegularExpressions; +using Serilog; + +namespace XIVLauncher2.Common.Game.Exceptions; + +[Serializable] +public class OauthLoginException : Exception +{ + private static Regex errorMessageRegex = + new(@"window.external.user\(""login=auth,ng,err,(?.*)\""\);", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public string? OauthErrorMessage { get; private set; } + + public OauthLoginException(string document) + : base(GetErrorMessage(document) ?? "Unknown error") + { + this.OauthErrorMessage = GetErrorMessage(document); + } + + private static string? GetErrorMessage(string document) + { + var matches = errorMessageRegex.Matches(document); + + if (matches.Count is 0 or > 1) + { + Log.Error("Could not get login error\n{Doc}", document); + return null; + } + + return matches[0].Groups["errorMessage"].Value; + } +} diff --git a/src/XIVLauncher2.Common/Game/Exceptions/SteamException.cs b/src/XIVLauncher2.Common/Game/Exceptions/SteamException.cs new file mode 100644 index 0000000..4124bd1 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Exceptions/SteamException.cs @@ -0,0 +1,11 @@ +using System; + +namespace XIVLauncher2.Common.Game.Exceptions; + +public class SteamException : Exception +{ + public SteamException(string message, Exception innerException = null) + : base(message, innerException) + { + } +} diff --git a/src/XIVLauncher2.Common/Game/Exceptions/SteamLinkNeededException.cs b/src/XIVLauncher2.Common/Game/Exceptions/SteamLinkNeededException.cs new file mode 100644 index 0000000..7bfeb58 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Exceptions/SteamLinkNeededException.cs @@ -0,0 +1,11 @@ +using System; + +namespace XIVLauncher2.Common.Game.Exceptions; + +public class SteamLinkNeededException : Exception +{ + public SteamLinkNeededException() + : base("No steam account linked.") + { + } +} diff --git a/src/XIVLauncher2.Common/Game/Exceptions/SteamWrongAccountException.cs b/src/XIVLauncher2.Common/Game/Exceptions/SteamWrongAccountException.cs new file mode 100644 index 0000000..6712995 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Exceptions/SteamWrongAccountException.cs @@ -0,0 +1,11 @@ +using System; + +namespace XIVLauncher2.Common.Game.Exceptions; + +public class SteamWrongAccountException : Exception +{ + public SteamWrongAccountException(string chosenUserName, string imposedUserName) + : base($"Wrong username! chosen: {chosenUserName}, imposed: {imposedUserName}") + { + } +} diff --git a/src/XIVLauncher2.Common/Game/GateStatus.cs b/src/XIVLauncher2.Common/Game/GateStatus.cs new file mode 100644 index 0000000..1e3a2a0 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/GateStatus.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace XIVLauncher2.Common.Game; + +public class GateStatus +{ + [JsonPropertyName("status")] + public bool Status { get; set; } + + [JsonPropertyName("message")] + public List Message { get; set; } + + [JsonPropertyName("news")] + public List News { get; set; } +} diff --git a/src/XIVLauncher2.Common/Game/Headlines.cs b/src/XIVLauncher2.Common/Game/Headlines.cs new file mode 100644 index 0000000..3f4eb73 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Headlines.cs @@ -0,0 +1,67 @@ +using System; +using System.Globalization; +using System.Text; +using System.Threading.Tasks; +using System.Text.Json; +using System.Text.Json.Serialization; +using XIVLauncher2.Common.Util; + +namespace XIVLauncher2.Common.Game +{ + public partial class Headlines + { + [JsonPropertyName("news")] + public News[] News { get; set; } + + [JsonPropertyName("topics")] + public News[] Topics { get; set; } + + [JsonPropertyName("pinned")] + public News[] Pinned { get; set; } + + [JsonPropertyName("banner")] + public Banner[] Banner { get; set; } + } + + public class Banner + { + [JsonPropertyName("lsb_banner")] + public Uri LsbBanner { get; set; } + + [JsonPropertyName("link")] + public Uri Link { get; set; } + } + + public class News + { + [JsonPropertyName("date")] + public DateTimeOffset Date { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("url")] + public string Url { get; set; } + + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("tag")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Tag { get; set; } + } + + public partial class Headlines + { + public static async Task Get(Launcher game, ClientLanguage language, bool forceNa = false) + { + var unixTimestamp = ApiHelpers.GetUnixMillis(); + var langCode = language.GetLangCode(forceNa); + var url = $"https://frontier.ffxiv.com/news/headline.json?lang={langCode}&media=pcapp&_={unixTimestamp}"; + + var json = Encoding.UTF8.GetString(await game.DownloadAsLauncher(url, language, "application/json, text/plain, */*").ConfigureAwait(false)); + + return JsonSerializer.Deserialize(json); + } + } +} diff --git a/src/XIVLauncher2.Common/Game/IntegrityCheck.cs b/src/XIVLauncher2.Common/Game/IntegrityCheck.cs new file mode 100644 index 0000000..e15d0e7 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/IntegrityCheck.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading.Tasks; + +namespace XIVLauncher2.Common.Game +{ + public static class IntegrityCheck + { + private const string INTEGRITY_CHECK_BASE_URL = "https://goatcorp.github.io/integrity/"; + + public class IntegrityCheckResult + { + public Dictionary Hashes { get; set; } + public string GameVersion { get; set; } + public string LastGameVersion { get; set; } + } + + public class IntegrityCheckProgress + { + public string CurrentFile { get; set; } + } + + public enum CompareResult + { + Valid, + Invalid, + ReferenceNotFound, + ReferenceFetchFailure, + } + + public static async Task<(CompareResult compareResult, string report, IntegrityCheckResult remoteIntegrity)> + CompareIntegrityAsync(IProgress progress, DirectoryInfo gamePath, bool onlyIndex = false) + { + IntegrityCheckResult remoteIntegrity; + + try + { + remoteIntegrity = DownloadIntegrityCheckForVersion(Repository.Ffxiv.GetVer(gamePath)); + } + catch (WebException e) + { + if (e.Response is HttpWebResponse resp && resp.StatusCode == HttpStatusCode.NotFound) + return (CompareResult.ReferenceNotFound, null, null); + return (CompareResult.ReferenceFetchFailure, null, null); + } + + var localIntegrity = await RunIntegrityCheckAsync(gamePath, progress, onlyIndex).ConfigureAwait(false); + + var report = ""; + var failed = false; + + foreach (var hashEntry in remoteIntegrity.Hashes) + { + if (onlyIndex && (!hashEntry.Key.EndsWith(".index", StringComparison.Ordinal) && !hashEntry.Key.EndsWith(".index2", StringComparison.Ordinal))) + continue; + + if (localIntegrity.Hashes.Any(h => h.Key == hashEntry.Key)) + { + if (localIntegrity.Hashes.First(h => h.Key == hashEntry.Key).Value != hashEntry.Value) + { + report += $"Mismatch: {hashEntry.Key}\n"; + failed = true; + } + } + else + { + report += $"Missing: {hashEntry.Key}\n"; + } + } + + return (failed ? CompareResult.Invalid : CompareResult.Valid, report, remoteIntegrity); + } + + private static IntegrityCheckResult DownloadIntegrityCheckForVersion(string gameVersion) + { + using (var client = new WebClient()) + { + return JsonSerializer.Deserialize( + client.DownloadString(INTEGRITY_CHECK_BASE_URL + gameVersion + ".json")); + } + } + + public static async Task RunIntegrityCheckAsync(DirectoryInfo gamePath, + IProgress progress, bool onlyIndex = false) + { + var hashes = new Dictionary(); + + using (var sha1 = SHA1.Create()) + { + CheckDirectory(gamePath, sha1, gamePath.FullName, ref hashes, progress, onlyIndex); + } + + return new IntegrityCheckResult + { + GameVersion = Repository.Ffxiv.GetVer(gamePath), + Hashes = hashes + }; + } + + private static void CheckDirectory(DirectoryInfo directory, SHA1 sha1, string rootDirectory, + ref Dictionary results, IProgress progress, bool onlyIndex = false) + { + foreach (var file in directory.GetFiles()) + { + var relativePath = file.FullName.Substring(rootDirectory.Length); + + // for unix compatibility with windows-generated integrity files. + relativePath = relativePath.Replace("/", "\\"); + + if (!relativePath.StartsWith("\\", StringComparison.Ordinal)) + relativePath = "\\" + relativePath; + + if (!relativePath.StartsWith("\\game", StringComparison.Ordinal)) + continue; + + if (onlyIndex && (!relativePath.EndsWith(".index", StringComparison.Ordinal) && !relativePath.EndsWith(".index2", StringComparison.Ordinal))) + continue; + + try + { + using (var stream = + new BufferedStream(file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite), 1200000)) + { + var hash = sha1.ComputeHash(stream); + + results.Add(relativePath, BitConverter.ToString(hash).Replace('-', ' ')); + + progress?.Report(new IntegrityCheckProgress + { + CurrentFile = relativePath + }); + } + } + catch (IOException) + { + // Ignore + } + } + + foreach (var dir in directory.GetDirectories()) + { + if (!dir.FullName.ToLower().Contains("shade")) //skip gshade directories. They just waste cpu + CheckDirectory(dir, sha1, rootDirectory, ref results, progress, onlyIndex); + } + } + } +} diff --git a/src/XIVLauncher2.Common/Game/Launcher.cs b/src/XIVLauncher2.Common/Game/Launcher.cs new file mode 100644 index 0000000..1068396 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Launcher.cs @@ -0,0 +1,702 @@ + + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; + +#if NET6_0_OR_GREATER && !WIN32 +using System.Net.Security; +#endif + +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Text.Json; +using Serilog; +using XIVLauncher2.Common.Encryption; +using XIVLauncher2.Common.Game.Exceptions; +using XIVLauncher2.Common.Game.Patch.PatchList; +using XIVLauncher2.Common.PlatformAbstractions; +using XIVLauncher2.Common.Util; + +namespace XIVLauncher2.Common.Game; + +public class Launcher +{ + private readonly ISteam? steam; + private readonly byte[]? steamTicket; + private readonly IUniqueIdCache uniqueIdCache; + private readonly ISettings settings; + private readonly HttpClient client; + private readonly string frontierUrlTemplate; + + private const string FALLBACK_FRONTIER_URL_TEMPLATE = "https://launcher.finalfantasyxiv.com/v620/index.html?rc_lang={0}&time={1}"; + + public Launcher(ISteam? steam, IUniqueIdCache uniqueIdCache, ISettings settings, string? frontierUrl = null) + { + this.steam = steam; + this.uniqueIdCache = uniqueIdCache; + this.settings = settings; + this.frontierUrlTemplate = + string.IsNullOrWhiteSpace(frontierUrl) ? FALLBACK_FRONTIER_URL_TEMPLATE : frontierUrl; + + ServicePointManager.Expect100Continue = false; + +#if NET6_0_OR_GREATER && !WIN32 + var sslOptions = new SslClientAuthenticationOptions() + { + CipherSuitesPolicy = new CipherSuitesPolicy(new[] { TlsCipherSuite.TLS_DHE_RSA_WITH_AES_256_GCM_SHA384 }) + }; + + var handler = new SocketsHttpHandler + { + UseCookies = false, + SslOptions = sslOptions, + }; +#else + var handler = new HttpClientHandler + { + UseCookies = false, + }; +#endif + + this.client = new HttpClient(handler); + } + + public Launcher(byte[] steamTicket, IUniqueIdCache uniqueIdCache, ISettings settings, string? frontierUrl = null) + : this(steam: null, uniqueIdCache, settings, frontierUrl) + { + this.steamTicket = steamTicket; + } + + // The user agent for frontier pages. {0} has to be replaced by a unique computer id and its checksum + private const string USER_AGENT_TEMPLATE = "SQEXAuthor/2.0.0(Windows 6.2; ja-jp; {0})"; + private readonly string _userAgent = GenerateUserAgent(); + + private static readonly string[] FilesToHash = + { + "ffxivboot.exe", + "ffxivboot64.exe", + "ffxivlauncher.exe", + "ffxivlauncher64.exe", + "ffxivupdater.exe", + "ffxivupdater64.exe" + }; + + public enum LoginState + { + Unknown, + Ok, + NeedsPatchGame, + NeedsPatchBoot, + NoService, + NoTerms + } + + public class LoginResult + { + public LoginState State { get; set; } + public PatchListEntry[] PendingPatches { get; set; } + public OauthLoginResult OauthLogin { get; set; } + public string UniqueId { get; set; } + } + + public async Task Login(string userName, string password, string otp, bool isSteam, bool useCache, DirectoryInfo gamePath, bool forceBaseVersion, bool isFreeTrial) + { + string uid; + PatchListEntry[] pendingPatches = null; + + OauthLoginResult oauthLoginResult; + + LoginState loginState; + + Log.Information("XivGame::Login(steamServiceAccount:{IsSteam}, cache:{UseCache})", isSteam, useCache); + + Ticket? steamTicket = null; + + if (isSteam) + { + if (this.steamTicket != null) + { + steamTicket = Ticket.EncryptAuthSessionTicket(this.steamTicket, (uint) DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + Log.Information("Using predefined steam ticket"); + } + else + { + Debug.Assert(this.steam != null); + + try + { + if (!this.steam.IsValid) + { + this.steam.Initialize(isFreeTrial ? Constants.STEAM_FT_APP_ID : Constants.STEAM_APP_ID); + } + } + catch (Exception ex) + { + Log.Error(ex, "Could not initialize Steam"); + throw new SteamException("SteamAPI_Init() failed.", ex); + } + + if (!this.steam.IsValid) + { + throw new SteamException("Steam did not initialize successfully. Please restart Steam and try again."); + } + + if (!this.steam.BLoggedOn) + { + throw new SteamException("Not logged into Steam, or Steam is running in offline mode. Please log in and try again."); + } + + try + { + steamTicket = await Ticket.Get(steam).ConfigureAwait(true); + } + catch (Exception ex) + { + throw new SteamException("Could not request auth ticket.", ex); + } + } + + if (steamTicket == null) + { + throw new SteamException("Steam auth ticket was null."); + } + } + + if (!useCache || !this.uniqueIdCache.TryGet(userName, out var cached)) + { + oauthLoginResult = await OauthLogin(userName, password, otp, isFreeTrial, isSteam, 3, steamTicket); + + Log.Information($"OAuth login successful - playable:{oauthLoginResult.Playable} terms:{oauthLoginResult.TermsAccepted} region:{oauthLoginResult.Region} expack:{oauthLoginResult.MaxExpansion}"); + + if (!oauthLoginResult.Playable) + { + return new LoginResult + { + State = LoginState.NoService + }; + } + + if (!oauthLoginResult.TermsAccepted) + { + return new LoginResult + { + State = LoginState.NoTerms + }; + } + + (uid, loginState, pendingPatches) = await RegisterSession(oauthLoginResult, gamePath, forceBaseVersion); + + if (useCache) + this.uniqueIdCache.Add(userName, uid, oauthLoginResult.Region, oauthLoginResult.MaxExpansion); + } + else + { + Log.Information("Cached UID found, using instead"); + uid = cached.UniqueId; + loginState = LoginState.Ok; + + oauthLoginResult = new OauthLoginResult + { + Playable = true, + Region = cached.Region, + TermsAccepted = true, + MaxExpansion = cached.MaxExpansion + }; + } + + return new LoginResult + { + PendingPatches = pendingPatches, + OauthLogin = oauthLoginResult, + State = loginState, + UniqueId = uid + }; + } + + public Process? LaunchGame(IGameRunner runner, string sessionId, int region, int expansionLevel, + bool isSteamServiceAccount, string additionalArguments, + DirectoryInfo gamePath, bool isDx11, ClientLanguage language, + bool encryptArguments, DpiAwareness dpiAwareness) + { + Log.Information( + $"XivGame::LaunchGame(steamServiceAccount:{isSteamServiceAccount}, args:{additionalArguments})"); + + var exePath = Path.Combine(gamePath.FullName, "game", "ffxiv_dx11.exe"); + if (!isDx11) + exePath = Path.Combine(gamePath.FullName, "game", "ffxiv.exe"); + + var environment = new Dictionary(); + + var argumentBuilder = new ArgumentBuilder() + .Append("DEV.DataPathType", "1") + .Append("DEV.MaxEntitledExpansionID", expansionLevel.ToString()) + .Append("DEV.TestSID", sessionId) + .Append("DEV.UseSqPack", "1") + .Append("SYS.Region", region.ToString()) + .Append("language", ((int)language).ToString()) + .Append("resetConfig", "0") + .Append("ver", Repository.Ffxiv.GetVer(gamePath)); + + if (isSteamServiceAccount) + { + // These environment variable and arguments seems to be set when ffxivboot is started with "-issteam" (27.08.2019) + environment.Add("IS_FFXIV_LAUNCH_FROM_STEAM", "1"); + argumentBuilder.Append("IsSteam", "1"); + } + + // This is a bit of a hack; ideally additionalArguments would be a dictionary or some KeyValue structure + if (!string.IsNullOrEmpty(additionalArguments)) + { + var regex = new Regex(@"\s*(?[^\s=]+)\s*=\s*(?([^=]*$|[^=]*\s(?=[^\s=]+)))\s*", RegexOptions.Compiled); + foreach (Match match in regex.Matches(additionalArguments)) + argumentBuilder.Append(match.Groups["key"].Value, match.Groups["value"].Value.Trim()); + } + + if (!File.Exists(exePath)) + throw new BinaryNotPresentException(exePath); + + var workingDir = Path.Combine(gamePath.FullName, "game"); + + var arguments = encryptArguments + ? argumentBuilder.BuildEncrypted() + : argumentBuilder.Build(); + + return runner.Start(exePath, workingDir, arguments, environment, dpiAwareness); + } + + private static string GetVersionReport(DirectoryInfo gamePath, int exLevel, bool forceBaseVersion) + { + var verReport = $"{GetBootVersionHash(gamePath)}"; + + if (exLevel >= 1) + verReport += $"\nex1\t{(forceBaseVersion ? Constants.BASE_GAME_VERSION : Repository.Ex1.GetVer(gamePath))}"; + + if (exLevel >= 2) + verReport += $"\nex2\t{(forceBaseVersion ? Constants.BASE_GAME_VERSION : Repository.Ex2.GetVer(gamePath))}"; + + if (exLevel >= 3) + verReport += $"\nex3\t{(forceBaseVersion ? Constants.BASE_GAME_VERSION : Repository.Ex3.GetVer(gamePath))}"; + + if (exLevel >= 4) + verReport += $"\nex4\t{(forceBaseVersion ? Constants.BASE_GAME_VERSION : Repository.Ex4.GetVer(gamePath))}"; + + return verReport; + } + + /// + /// Check ver & bck files for sanity. + /// + /// + /// + private static void EnsureVersionSanity(DirectoryInfo gamePath, int exLevel) + { + var failed = IsBadVersionSanity(gamePath, Repository.Ffxiv); + failed |= IsBadVersionSanity(gamePath, Repository.Ffxiv, true); + + if (exLevel >= 1) + { + failed |= IsBadVersionSanity(gamePath, Repository.Ex1); + failed |= IsBadVersionSanity(gamePath, Repository.Ex1, true); + } + + if (exLevel >= 2) + { + failed |= IsBadVersionSanity(gamePath, Repository.Ex2); + failed |= IsBadVersionSanity(gamePath, Repository.Ex2, true); + } + + if (exLevel >= 3) + { + failed |= IsBadVersionSanity(gamePath, Repository.Ex3); + failed |= IsBadVersionSanity(gamePath, Repository.Ex3, true); + } + + if (exLevel >= 4) + { + failed |= IsBadVersionSanity(gamePath, Repository.Ex4); + failed |= IsBadVersionSanity(gamePath, Repository.Ex4, true); + } + + if (failed) + throw new InvalidVersionFilesException(); + } + + private static bool IsBadVersionSanity(DirectoryInfo gamePath, Repository repo, bool isBck = false) + { + var text = repo.GetVer(gamePath, isBck); + + var nullOrWhitespace = string.IsNullOrWhiteSpace(text); + var containsNewline = text.Contains("\n"); + var allNullBytes = Encoding.UTF8.GetBytes(text).All(x => x == 0x00); + + if (nullOrWhitespace || containsNewline || allNullBytes) + { + Log.Error("Sanity check failed for {repo}/{isBck}: {NullOrWhitespace}, {ContainsNewline}, {AllNullBytes}", repo, isBck, nullOrWhitespace, containsNewline, allNullBytes); + return true; + } + + return false; + } + + /// + /// Calculate the hash that is sent to patch-gamever for version verification/tamper protection. + /// This same hash is also sent in lobby, but for ffxiv.exe and ffxiv_dx11.exe. + /// + /// String of hashed EXE files. + private static string GetBootVersionHash(DirectoryInfo gamePath) + { + var result = Repository.Boot.GetVer(gamePath) + "="; + + for (var i = 0; i < FilesToHash.Length; i++) + { + result += + $"{FilesToHash[i]}/{GetFileHash(Path.Combine(gamePath.FullName, "boot", FilesToHash[i]))}"; + + if (i != FilesToHash.Length - 1) + result += ","; + } + + return result; + } + + public async Task CheckBootVersion(DirectoryInfo gamePath) + { + var request = new HttpRequestMessage(HttpMethod.Get, + $"http://patch-bootver.ffxiv.com/http/win32/ffxivneo_release_boot/{Repository.Boot.GetVer(gamePath)}/?time=" + + GetLauncherFormattedTimeLongRounded()); + + request.Headers.AddWithoutValidation("User-Agent", Constants.PatcherUserAgent); + request.Headers.AddWithoutValidation("Host", "patch-bootver.ffxiv.com"); + + var resp = await this.client.SendAsync(request); + var text = await resp.Content.ReadAsStringAsync(); + + if (text == string.Empty) + return null; + + Log.Verbose("Boot patching is needed... List:\n{PatchList}", resp); + + try + { + return PatchListParser.Parse(text); + } + catch (PatchListParseException ex) + { + Log.Information("Patch list:\n{PatchList}", ex.List); + throw; + } + } + + private async Task<(string Uid, LoginState result, PatchListEntry[] PendingGamePatches)> RegisterSession(OauthLoginResult loginResult, DirectoryInfo gamePath, bool forceBaseVersion) + { + var request = new HttpRequestMessage(HttpMethod.Post, + $"https://patch-gamever.ffxiv.com/http/win32/ffxivneo_release_game/{(forceBaseVersion ? Constants.BASE_GAME_VERSION : Repository.Ffxiv.GetVer(gamePath))}/{loginResult.SessionId}"); + + request.Headers.AddWithoutValidation("X-Hash-Check", "enabled"); + request.Headers.AddWithoutValidation("User-Agent", Constants.PatcherUserAgent); + + if (!forceBaseVersion) + EnsureVersionSanity(gamePath, loginResult.MaxExpansion); + request.Content = new StringContent(GetVersionReport(gamePath, loginResult.MaxExpansion, forceBaseVersion)); + + var resp = await this.client.SendAsync(request); + var text = await resp.Content.ReadAsStringAsync(); + + // Conflict indicates that boot needs to update, we do not get a patch list or a unique ID to download patches with in this case + if (resp.StatusCode == HttpStatusCode.Conflict) + return (null, LoginState.NeedsPatchBoot, null); + + if (resp.StatusCode == HttpStatusCode.Gone) + throw new InvalidResponseException("The server indicated that the version requested is no longer being serviced or not present.", text); + + if (!resp.Headers.TryGetValues("X-Patch-Unique-Id", out var uidVals)) + throw new InvalidResponseException($"Could not get X-Patch-Unique-Id. ({resp.StatusCode})", text); + + var uid = uidVals.First(); + + if (string.IsNullOrEmpty(text)) + return (uid, LoginState.Ok, null); + + Log.Verbose("Game Patching is needed... List:\n{PatchList}", text); + + var pendingPatches = PatchListParser.Parse(text); + return (uid, LoginState.NeedsPatchGame, pendingPatches); + } + + public async Task GenPatchToken(string patchUrl, string uniqueId) + { + // Yes, Square does use HTTP for this and sends tokens in headers. IT'S NOT MY FAULT. + var request = new HttpRequestMessage(HttpMethod.Post, "http://patch-gamever.ffxiv.com/gen_token"); + + request.Headers.AddWithoutValidation("Connection", "Keep-Alive"); + request.Headers.AddWithoutValidation("X-Patch-Unique-Id", uniqueId); + request.Headers.AddWithoutValidation("User-Agent", Constants.PatcherUserAgent); + + request.Content = new StringContent(patchUrl); + + var resp = await this.client.SendAsync(request); + resp.EnsureSuccessStatusCode(); + + return await resp.Content.ReadAsStringAsync(); + } + + private async Task<(string Stored, string? SteamLinkedId)> GetOauthTop(string url, bool isSteam) + { + // This is needed to be able to access the login site correctly + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.AddWithoutValidation("Accept", "image/gif, image/jpeg, image/pjpeg, application/x-ms-application, application/xaml+xml, application/x-ms-xbap, */*"); + request.Headers.AddWithoutValidation("Referer", GenerateFrontierReferer(this.settings.ClientLanguage.GetValueOrDefault(ClientLanguage.English))); + request.Headers.AddWithoutValidation("Accept-Encoding", "gzip, deflate"); + request.Headers.AddWithoutValidation("Accept-Language", this.settings.AcceptLanguage); + request.Headers.AddWithoutValidation("User-Agent", _userAgent); + request.Headers.AddWithoutValidation("Connection", "Keep-Alive"); + request.Headers.AddWithoutValidation("Cookie", "_rsid=\"\""); + + var reply = await this.client.SendAsync(request); + + var text = await reply.Content.ReadAsStringAsync(); + + if (text.Contains("window.external.user(\"restartup\");")) + { + if (isSteam) + throw new SteamLinkNeededException(); + + throw new InvalidResponseException("restartup, but not isSteam?", text); + } + + var storedRegex = new Regex(@"\t<\s*input .* name=""_STORED_"" value=""(?.*)"">"); + var matches = storedRegex.Matches(text); + + if (matches.Count == 0) + { + Log.Error(text); + throw new InvalidResponseException("Could not get STORED.", text); + } + + string? steamUsername = null; + + if (isSteam) + { + var steamRegex = new Regex(@".*)""\/>"); + var steamMatches = steamRegex.Matches(text); + + if (steamMatches.Count == 0) + { + Log.Error(text); + throw new InvalidResponseException("Could not get steam username.", text); + } + + steamUsername = steamMatches[0].Groups["sqexid"].Value; + } + + return (matches[0].Groups["stored"].Value, steamUsername); + } + + public class OauthLoginResult + { + public string SessionId { get; set; } + public int Region { get; set; } + public bool TermsAccepted { get; set; } + public bool Playable { get; set; } + public int MaxExpansion { get; set; } + } + + private static string GetOauthTopUrl(int region, bool isFreeTrial, bool isSteam, Ticket steamTicket) + { + var url = + $"https://ffxiv-login.square-enix.com/oauth/ffxivarr/login/top?lng=en&rgn={region}&isft={(isFreeTrial ? "1" : "0")}&cssmode=1&isnew=1&launchver=3"; + + if (isSteam) + { + url += "&issteam=1"; + + url += $"&session_ticket={steamTicket.Text}"; + url += $"&ticket_size={steamTicket.Length}"; + } + + return url; + } + + private async Task OauthLogin(string userName, string password, string otp, bool isFreeTrial, bool isSteam, int region, Ticket? steamTicket) + { + if (isSteam && steamTicket == null) + throw new ArgumentNullException(nameof(steamTicket), "isSteam, but steamTicket == null"); + + var topUrl = GetOauthTopUrl(region, isFreeTrial, isSteam, steamTicket); + var topResult = await GetOauthTop(topUrl, isSteam); + + var request = new HttpRequestMessage(HttpMethod.Post, + "https://ffxiv-login.square-enix.com/oauth/ffxivarr/login/login.send"); + + request.Headers.AddWithoutValidation("Accept", "image/gif, image/jpeg, image/pjpeg, application/x-ms-application, application/xaml+xml, application/x-ms-xbap, */*"); + request.Headers.AddWithoutValidation("Referer", topUrl); + request.Headers.AddWithoutValidation("Accept-Language", this.settings.AcceptLanguage); + request.Headers.AddWithoutValidation("User-Agent", _userAgent); + //request.Headers.AddWithoutValidation("Content-Type", "application/x-www-form-urlencoded"); + request.Headers.AddWithoutValidation("Accept-Encoding", "gzip, deflate"); + request.Headers.AddWithoutValidation("Host", "ffxiv-login.square-enix.com"); + request.Headers.AddWithoutValidation("Connection", "Keep-Alive"); + request.Headers.AddWithoutValidation("Cache-Control", "no-cache"); + request.Headers.AddWithoutValidation("Cookie", "_rsid=\"\""); + + if (isSteam) + { + if (!String.Equals(userName, topResult.SteamLinkedId, StringComparison.OrdinalIgnoreCase)) + throw new SteamWrongAccountException(userName, topResult.SteamLinkedId); + + userName = topResult.SteamLinkedId; + } + + request.Content = new FormUrlEncodedContent( + new Dictionary() + { + { "_STORED_", topResult.Stored }, + { "sqexid", userName }, + { "password", password }, + { "otppw", otp }, + // { "saveid", "1" } // NOTE(goat): This adds a Set-Cookie with a filled-out _rsid value in the login response. + }); + + var response = await this.client.SendAsync(request); + + var reply = await response.Content.ReadAsStringAsync(); + + var regex = new Regex(@"window.external.user\(""login=auth,ok,(?.*)\);"); + var matches = regex.Matches(reply); + + if (matches.Count == 0) + throw new OauthLoginException(reply); + + var launchParams = matches[0].Groups["launchParams"].Value.Split(','); + + return new OauthLoginResult + { + SessionId = launchParams[1], + Region = int.Parse(launchParams[5]), + TermsAccepted = launchParams[3] != "0", + Playable = launchParams[9] != "0", + MaxExpansion = int.Parse(launchParams[13]) + }; + } + + private static string GetFileHash(string file) + { + var bytes = File.ReadAllBytes(file); + + var hash = SHA1.Create().ComputeHash(bytes); + var hashstring = string.Join("", hash.Select(b => b.ToString("x2")).ToArray()); + + var length = new FileInfo(file).Length; + + return length + "/" + hashstring; + } + + public async Task GetGateStatus(ClientLanguage language) + { + try + { + var reply = Encoding.UTF8.GetString( + await DownloadAsLauncher( + $"https://frontier.ffxiv.com/worldStatus/gate_status.json?lang={language.GetLangCode()}&_={ApiHelpers.GetUnixMillis()}", language).ConfigureAwait(true)); + + return JsonSerializer.Deserialize(reply); + } + catch (Exception exc) + { + throw new Exception("Could not get gate status", exc); + } + } + + public async Task GetLoginStatus() + { + try + { + var reply = Encoding.UTF8.GetString( + await DownloadAsLauncher( + $"https://frontier.ffxiv.com/worldStatus/login_status.json?_={ApiHelpers.GetUnixMillis()}", ClientLanguage.English).ConfigureAwait(true)); + + return Convert.ToBoolean(int.Parse(reply[10].ToString())); + } + catch (Exception exc) + { + throw new Exception("Could not get gate status", exc); + } + } + + private static string MakeComputerId() + { + var hashString = Environment.MachineName + Environment.UserName + Environment.OSVersion + + Environment.ProcessorCount; + + using var sha1 = HashAlgorithm.Create("SHA1"); + + var bytes = new byte[5]; + + Array.Copy(sha1.ComputeHash(Encoding.Unicode.GetBytes(hashString)), 0, bytes, 1, 4); + + var checkSum = (byte) -(bytes[1] + bytes[2] + bytes[3] + bytes[4]); + bytes[0] = checkSum; + + return BitConverter.ToString(bytes).Replace("-", "").ToLower(); + } + + public async Task DownloadAsLauncher(string url, ClientLanguage language, string contentType = "") + { + var request = new HttpRequestMessage(HttpMethod.Get, url); + + request.Headers.AddWithoutValidation("User-Agent", _userAgent); + + if (!string.IsNullOrEmpty(contentType)) + { + request.Headers.AddWithoutValidation("Accept", contentType); + } + + request.Headers.AddWithoutValidation("Accept-Encoding", "gzip, deflate"); + request.Headers.AddWithoutValidation("Accept-Language", this.settings.AcceptLanguage); + + request.Headers.AddWithoutValidation("Origin", "https://launcher.finalfantasyxiv.com"); + + request.Headers.AddWithoutValidation("Referer", GenerateFrontierReferer(language)); + + var resp = await this.client.SendAsync(request); + return await resp.Content.ReadAsByteArrayAsync(); + } + + private string GenerateFrontierReferer(ClientLanguage language) + { + var langCode = language.GetLangCode().Replace("-", "_"); + var formattedTime = GetLauncherFormattedTimeLong(); + + return string.Format(this.frontierUrlTemplate, langCode, formattedTime); + } + + // Used to be used for frontier top, they now use the un-rounded long timestamp + private static string GetLauncherFormattedTime() => DateTime.UtcNow.ToString("yyyy-MM-dd-HH"); + + private static string GetLauncherFormattedTimeLong() => DateTime.UtcNow.ToString("yyyy-MM-dd-HH-mm"); + + private static string GetLauncherFormattedTimeLongRounded() + { + var formatted = DateTime.UtcNow.ToString("yyyy-MM-dd-HH-mm", new CultureInfo("en-US")).ToCharArray(); + formatted[15] = '0'; + + return new string(formatted); + } + + private static string GenerateUserAgent() + { + return string.Format(USER_AGENT_TEMPLATE, MakeComputerId()); + } +} + +#nullable restore diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/AcquisitionMethod.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/AcquisitionMethod.cs new file mode 100644 index 0000000..95926e5 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/AcquisitionMethod.cs @@ -0,0 +1,17 @@ +namespace XIVLauncher2.Common.Game.Patch.Acquisition +{ + public enum AcquisitionMethod + { + [SettingsDescription(".NET", "Basic .NET downloads")] + NetDownloader, + + [SettingsDescription("Torrent (+ .NET)", "Torrent downloads, with .NET as a fallback")] + MonoTorrentNetFallback, + + [SettingsDescription("Torrent (+ Aria)", "Torrent downloads, with Aria as a fallback")] + MonoTorrentAriaFallback, + + [SettingsDescription("Aria2c", "Aria2c downloads (recommended)")] + Aria, + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/AcquisitionProgress.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/AcquisitionProgress.cs new file mode 100644 index 0000000..440cbd1 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/AcquisitionProgress.cs @@ -0,0 +1,8 @@ +namespace XIVLauncher2.Common.Game.Patch.Acquisition +{ + public class AcquisitionProgress + { + public long Progress { get; set; } + public long BytesPerSecondSpeed { get; set; } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/AcquisitionResult.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/AcquisitionResult.cs new file mode 100644 index 0000000..5ff2d15 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/AcquisitionResult.cs @@ -0,0 +1,9 @@ +namespace XIVLauncher2.Common.Game.Patch.Acquisition +{ + public enum AcquisitionResult + { + Success, + Error, + Cancelled, + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/AriaHttpPatchAcquisition.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/AriaHttpPatchAcquisition.cs new file mode 100644 index 0000000..611e46e --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/AriaHttpPatchAcquisition.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AriaNet; +using Serilog; +using XIVLauncher2.Common.Util; + +namespace XIVLauncher2.Common.Game.Patch.Acquisition.Aria +{ + public class AriaHttpPatchAcquisition : PatchAcquisition + { + private static Process ariaProcess; + private static AriaManager manager; + private static long maxDownloadSpeed; + + public static async Task InitializeAsync(long maxDownloadSpeed, FileInfo logFile) + { + AriaHttpPatchAcquisition.maxDownloadSpeed = maxDownloadSpeed; + + if (ariaProcess == null || ariaProcess.HasExited) + { + // Kill stray aria2c-xl processes + var stray = Process.GetProcessesByName("aria2c-xl"); + + foreach (var process in stray) + { + try + { + process.Kill(); + } + catch (Exception ex) + { + Log.Error(ex, "[ARIA] Could not kill stray process."); + } + } + + // I don't really see the point of this, but aria complains if we don't provide a secret + var rng = new Random(); + var secret = BitConverter.ToString(MD5.Create().ComputeHash(Encoding.UTF8.GetBytes($"{rng.Next()}{rng.Next()}{rng.Next()}{rng.Next()}"))); + + var ariaPath = Path.Combine(Paths.ResourcesPath, "aria2c-xl.exe"); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + ariaPath = "aria2c"; + } + + var ariaPort = PlatformHelpers.GetAvailablePort(); + var ariaHost = $"http://localhost:{ariaPort}/jsonrpc"; + + var ariaArgs = + $"--enable-rpc --rpc-secret={secret} --rpc-listen-port={ariaPort} --log=\"{logFile.FullName}\" --log-level=notice --max-connection-per-server=8 --auto-file-renaming=false --allow-overwrite=true"; + + Log.Verbose($"[ARIA] Aria process not there, creating from {ariaPath} {ariaArgs}..."); + + var startInfo = new ProcessStartInfo(ariaPath, ariaArgs) + { +#if !DEBUG + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, +#endif + UseShellExecute = false, + }; + + ariaProcess = Process.Start(startInfo); + + Thread.Sleep(400); + + if (ariaProcess == null) + throw new Exception("ariaProcess was null."); + + if (ariaProcess.HasExited) + throw new Exception("ariaProcess has exited."); + + manager = new AriaManager(secret, ariaHost); + } + } + + public static async Task UnInitializeAsync() + { + if (ariaProcess is {HasExited: false}) + { + try + { + await manager.Shutdown(); + } + catch (Exception) + { + // ignored + } + + Thread.Sleep(1000); + + if (!ariaProcess.HasExited) + ariaProcess.Kill(); + } + } + + public override async Task StartDownloadAsync(string url, FileInfo outFile) + { + await manager.AddUri(new List() + { + url + }, new Dictionary() + { + {"user-agent", Constants.PatcherUserAgent}, + {"out", outFile.Name}, + {"dir", outFile.Directory.FullName}, + {"max-connection-per-server", "8"}, + {"max-tries", "100"}, + {"max-download-limit", maxDownloadSpeed.ToString()}, + {"auto-file-renaming", "false"}, + {"allow-overwrite", "true"}, + }).ContinueWith(t => + { + if (t.IsFaulted || t.IsCanceled) + { + Log.Error(t.Exception, $"[ARIA] Could not send download RPC for {url}"); + OnComplete(AcquisitionResult.Error); + return; + } + + var gid = t.Result; + + Log.Verbose($"[ARIA] GID# {gid} for {url}"); + + var _ = Task.Run(async () => + { + while (true) + { + try + { + var status = await manager.GetStatus(gid); + + if (status.Status == "complete") + { + Log.Verbose($"[ARIA] GID# {gid} for {url} SUCCESS"); + + OnComplete(AcquisitionResult.Success); + return; + } + + if (status.Status == "removed") + { + Log.Verbose($"[ARIA] GID# {gid} for {url} CANCEL"); + + OnComplete(AcquisitionResult.Cancelled); + return; + } + + if (status.Status == "error") + { + Log.Verbose($"[ARIA] GID# {gid} for {url} FAULTED"); + + OnComplete(AcquisitionResult.Error); + return; + } + + OnProgressChanged(new AcquisitionProgress + { + BytesPerSecondSpeed = long.Parse(status.DownloadSpeed), + Progress = long.Parse(status.CompletedLength), + }); + } + catch (Exception ex) + { + Log.Error(ex, $"[ARIA] Failed to get status for GID# {gid} ({url})"); + } + + Thread.Sleep(500); + } + }); + }); + } + + public override async Task CancelAsync() + { + await manager.PauseAllTasks(); + } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/AriaManager.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/AriaManager.cs new file mode 100644 index 0000000..b9b6727 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/AriaManager.cs @@ -0,0 +1,201 @@ +/** + * This file is part of AriaNet by huming2207, licensed under the CC-BY-NC-SA 3.0 Australian Licence. + * You can find the original code in this GitHub repository: https://github.com/huming2207/AriaNet + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using AriaNet.Attributes; +using XIVLauncher2.Common.Game.Patch.Acquisition.Aria.JsonRpc; + +namespace AriaNet +{ + public class AriaManager + { + private readonly JsonRpcHttpClient rpcClient; + private readonly string secret; + + public AriaManager(string secret, string rpcUrl = "http://localhost:6800/jsonrpc") + { + this.secret = secret; + this.rpcClient = new JsonRpcHttpClient(rpcUrl); + } + + private async Task Invoke(string method, params object[] arguments) + { + var args = new object[arguments.Length + 1]; + args[0] = $"token:{this.secret}"; + Array.Copy(arguments, 0, args, 1, arguments.Length); + + return await this.rpcClient.Invoke(method, args); + } + + public async Task AddUri(List uriList) + { + return await Invoke("aria2.addUri", uriList); + } + + public async Task AddUri(List uriList, string userAgent, string referrer) + { + return await Invoke("aria2.addUri", uriList, + new Dictionary + { + {"user-agent", userAgent}, + {"referer", referrer} + }); + } + + public async Task AddUri(List uriList, Dictionary options) + { + return await Invoke("aria2.addUri", uriList, options); + } + + public async Task AddMetaLink(string filePath) + { + var metaLinkBase64 = Convert.ToBase64String(File.ReadAllBytes(filePath)); + return await Invoke("aria2.addMetalink", metaLinkBase64); + } + + public async Task AddTorrent(string filePath) + { + var torrentBase64 = Convert.ToBase64String(File.ReadAllBytes(filePath)); + return await Invoke("aria2.addTorrent", torrentBase64); + } + + public async Task RemoveTask(string gid, bool forceRemove = false) + { + if (!forceRemove) + { + return await Invoke("aria2.remove", gid); + } + else + { + return await Invoke("aria2.forceRemove", gid); + } + } + + public async Task PauseTask(string gid, bool forcePause = false) + { + if (!forcePause) + { + return await Invoke("aria2.pause", gid); + } + else + { + return await Invoke("aria2.forcePause", gid); + } + } + + public async Task PauseAllTasks() + { + return (await Invoke("aria2.pauseAll")).Contains("OK"); + } + + public async Task UnpauseAllTasks() + { + return (await Invoke("aria2.unpauseAll")).Contains("OK"); + } + + public async Task UnpauseTask(string gid) + { + return await Invoke("aria2.unpause", gid); + } + + public async Task GetStatus(string gid) + { + return await Invoke("aria2.tellStatus", gid); + } + + public async Task GetUris(string gid) + { + return await Invoke("aria2.getUris", gid); + } + + public async Task GetFiles(string gid) + { + return await Invoke("aria2.getFiles", gid); + } + + public async Task GetPeers(string gid) + { + return await Invoke("aria2.getPeers", gid); + } + + public async Task GetServers(string gid) + { + return await Invoke("aria2.getServers", gid); + } + + public async Task GetActiveStatus(string gid) + { + return await Invoke("aria2.tellActive", gid); + } + public async Task GetOption(string gid) + { + return await Invoke("aria2.getOption", gid); + } + + + public async Task ChangeOption(string gid, AriaOption option) + { + return (await Invoke("aria2.changeOption", gid, option)) + .Contains("OK"); + } + + public async Task GetGlobalOption() + { + return await Invoke("aria2.getGlobalOption"); + } + + public async Task ChangeGlobalOption(AriaOption option) + { + return (await Invoke("aria2.changeGlobalOption", option)) + .Contains("OK"); + } + + public async Task GetGlobalStatus() + { + return await Invoke("aria2.getGlobalStat"); + } + + public async Task PurgeDownloadResult() + { + return (await Invoke("aria2.purgeDownloadResult")).Contains("OK"); + } + + public async Task RemoveDownloadResult(string gid) + { + return (await Invoke("aria2.removeDownloadResult", gid)) + .Contains("OK"); + } + + public async Task GetVersion() + { + return await Invoke("aria2.getVersion"); + } + + public async Task GetSessionInfo() + { + return await Invoke("aria2.getSessionInfo"); + } + + public async Task Shutdown(bool forceShutdown = false) + { + if (!forceShutdown) + { + return (await Invoke("aria2.shutdown")).Contains("OK"); + } + else + { + return (await Invoke("aria2.forceShutdown")).Contains("OK"); + } + } + + public async Task SaveSession() + { + return (await Invoke("aria2.saveSession")).Contains("OK"); + } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaFile.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaFile.cs new file mode 100644 index 0000000..bfd2631 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaFile.cs @@ -0,0 +1,31 @@ +/** + * This file is part of AriaNet by huming2207, licensed under the CC-BY-NC-SA 3.0 Australian Licence. + * You can find the original code in this GitHub repository: https://github.com/huming2207/AriaNet + */ + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace AriaNet.Attributes +{ + public class AriaFile + { + [JsonPropertyName("index")] + public string Index { get; set; } + + [JsonPropertyName("length")] + public string Length { get; set; } + + [JsonPropertyName("completedLength")] + public string CompletedLength { get; set; } + + [JsonPropertyName("path")] + public string Path { get; set; } + + [JsonPropertyName("selected")] + public string Selected { get; set; } + + [JsonPropertyName("uris")] + public List Uris { get; set; } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaGlobalStatus.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaGlobalStatus.cs new file mode 100644 index 0000000..d6ab0cb --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaGlobalStatus.cs @@ -0,0 +1,27 @@ +/** + * This file is part of AriaNet by huming2207, licensed under the CC-BY-NC-SA 3.0 Australian Licence. + * You can find the original code in this GitHub repository: https://github.com/huming2207/AriaNet + */ + +using System.Text.Json.Serialization; + +namespace AriaNet.Attributes +{ + public class AriaGlobalStatus + { + [JsonPropertyName("downloadSpeed")] + public int DownloadSpeed { get; set; } + + [JsonPropertyName("numActive")] + public int ActiveTaskCount { get; set; } + + [JsonPropertyName("numStopped")] + public int StoppedTaskCount { get; set; } + + [JsonPropertyName("numWaiting")] + public int WaitingTaskCount { get; set; } + + [JsonPropertyName("uploadSpeed")] + public int UploadSpeed { get; set; } + } +} \ No newline at end of file diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaOption.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaOption.cs new file mode 100644 index 0000000..2092ce3 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaOption.cs @@ -0,0 +1,339 @@ +/** + * This file is part of AriaNet by huming2207, licensed under the CC-BY-NC-SA 3.0 Australian Licence. + * You can find the original code in this GitHub repository: https://github.com/huming2207/AriaNet + */ + +using System.Text.Json.Serialization; + +namespace AriaNet.Attributes +{ + public class AriaOption + { + [JsonPropertyName("all-proxy")] + public string AllProxy { get; set; } + + [JsonPropertyName("all-proxy-passwd")] + public string AllProxyPasswd { get; set; } + + [JsonPropertyName("all-proxy-user")] + public string AllProxyUser { get; set; } + + [JsonPropertyName("allow-overwrite")] + public string AllowOverwrite { get; set; } + + [JsonPropertyName("allow-piece-length-change")] + public string AllowPieceLengthChange { get; set; } + + [JsonPropertyName("always-resume")] + public string AlwaysResume { get; set; } + + [JsonPropertyName("async-dns")] + public string AsyncDns { get; set; } + + [JsonPropertyName("auto-file-renaming")] + public string AutoFileRenaming { get; set; } + + [JsonPropertyName("bt-enable-hook-after-hash-check")] + public string BtEnableHookAfterHashCheck { get; set; } + + [JsonPropertyName("bt-enable-lpd")] + public string BtEnableLpd { get; set; } + + [JsonPropertyName("bt-exclude-tracker")] + public string BtExcludeTracker { get; set; } + + [JsonPropertyName("bt-external-ip")] + public string BtExternalIp { get; set; } + + [JsonPropertyName("bt-force-encryption")] + public string BtForceEncryption { get; set; } + + [JsonPropertyName("bt-hash-check-seed")] + public string BtHashCheckSeed { get; set; } + + [JsonPropertyName("bt-max-peers")] + public string BtMaxPeers { get; set; } + + [JsonPropertyName("bt-metadata-only")] + public string BtMetadataOnly { get; set; } + + [JsonPropertyName("bt-min-crypto-level")] + public string BtMinCryptoLevel { get; set; } + + [JsonPropertyName("bt-prioritize-piece")] + public string BtPrioritizePiece { get; set; } + + [JsonPropertyName("bt-remove-unselected-file")] + public string BtRemoveUnselectedFile { get; set; } + + [JsonPropertyName("bt-request-peer-speed-limit")] + public string BtRequestPeerSpeedLimit { get; set; } + + [JsonPropertyName("bt-require-crypto")] + public string BtRequireCrypto { get; set; } + + [JsonPropertyName("bt-save-metadata")] + public string BtSaveMetadata { get; set; } + + [JsonPropertyName("bt-seed-unverified")] + public string BtSeedUnverified { get; set; } + + [JsonPropertyName("bt-stop-timeout")] + public string BtStopTimeout { get; set; } + + [JsonPropertyName("bt-tracker")] + public string BtTracker { get; set; } + + [JsonPropertyName("bt-tracker-connect-timeout")] + public string BtTrackerConnectTimeout { get; set; } + + [JsonPropertyName("bt-tracker-interval")] + public string BtTrackerInterval { get; set; } + + [JsonPropertyName("bt-tracker-timeout")] + public string BtTrackerTimeout { get; set; } + + [JsonPropertyName("check-integrity")] + public string CheckIntegrity { get; set; } + + [JsonPropertyName("checksum")] + public string Checksum { get; set; } + + [JsonPropertyName("conditional-get")] + public string ConditionalGet { get; set; } + + [JsonPropertyName("connect-timeout")] + public string ConnectTimeout { get; set; } + + [JsonPropertyName("content-disposition-default-utf8")] + public string ContentDispositionDefaultUtf8 { get; set; } + + [JsonPropertyName("continue")] + public string Continue { get; set; } + + [JsonPropertyName("dir")] + public string Dir { get; set; } + + [JsonPropertyName("dry-run")] + public string DryRun { get; set; } + + [JsonPropertyName("enable-http-keep-alive")] + public string EnableHttpKeepAlive { get; set; } + + [JsonPropertyName("enable-http-pipelining")] + public string EnableHttpPipelining { get; set; } + + [JsonPropertyName("enable-mmap")] + public string EnableMmap { get; set; } + + [JsonPropertyName("enable-peer-exchange")] + public string EnablePeerExchange { get; set; } + + [JsonPropertyName("file-allocation")] + public string FileAllocation { get; set; } + + [JsonPropertyName("follow-metalink")] + public string FollowMetalink { get; set; } + + [JsonPropertyName("follow-torrent")] + public string FollowTorrent { get; set; } + + [JsonPropertyName("force-save")] + public string ForceSave { get; set; } + + [JsonPropertyName("ftp-passwd")] + public string FtpPasswd { get; set; } + + [JsonPropertyName("ftp-pasv")] + public string FtpPasv { get; set; } + + [JsonPropertyName("ftp-proxy")] + public string FtpProxy { get; set; } + + [JsonPropertyName("ftp-proxy-passwd")] + public string FtpProxyPasswd { get; set; } + + [JsonPropertyName("ftp-proxy-user")] + public string FtpProxyUser { get; set; } + + [JsonPropertyName("ftp-reuse-connection")] + public string FtpReuseConnection { get; set; } + + [JsonPropertyName("ftp-type")] + public string FtpType { get; set; } + + [JsonPropertyName("ftp-user")] + public string FtpUser { get; set; } + + [JsonPropertyName("gid")] + public string Gid { get; set; } + + [JsonPropertyName("hash-check-only")] + public string HashCheckOnly { get; set; } + + [JsonPropertyName("header")] + public string Header { get; set; } + + [JsonPropertyName("http-accept-gzip")] + public string HttpAcceptGzip { get; set; } + + [JsonPropertyName("http-auth-challenge")] + public string HttpAuthChallenge { get; set; } + + [JsonPropertyName("http-no-cache")] + public string HttpNoCache { get; set; } + + [JsonPropertyName("http-passwd")] + public string HttpPasswd { get; set; } + + [JsonPropertyName("http-proxy")] + public string HttpProxy { get; set; } + + [JsonPropertyName("http-proxy-passwd")] + public string HttpProxyPasswd { get; set; } + + [JsonPropertyName("http-proxy-user")] + public string HttpProxyUser { get; set; } + + [JsonPropertyName("http-user")] + public string HttpUser { get; set; } + + [JsonPropertyName("https-proxy")] + public string HttpsProxy { get; set; } + + [JsonPropertyName("https-proxy-passwd")] + public string HttpsProxyPasswd { get; set; } + + [JsonPropertyName("https-proxy-user")] + public string HttpsProxyUser { get; set; } + + [JsonPropertyName("index-out")] + public string IndexOut { get; set; } + + [JsonPropertyName("lowest-speed-limit")] + public string LowestSpeedLimit { get; set; } + + [JsonPropertyName("max-connection-per-server")] + public string MaxConnectionPerServer { get; set; } + + [JsonPropertyName("max-download-limit")] + public string MaxDownloadLimit { get; set; } + + [JsonPropertyName("max-file-not-found")] + public string MaxFileNotFound { get; set; } + + [JsonPropertyName("max-mmap-limit")] + public string MaxMmapLimit { get; set; } + + [JsonPropertyName("max-resume-failure-tries")] + public string MaxResumeFailureTries { get; set; } + + [JsonPropertyName("max-tries")] + public string MaxTries { get; set; } + + [JsonPropertyName("max-upload-limit")] + public string MaxUploadLimit { get; set; } + + [JsonPropertyName("metalink-base-uri")] + public string MetalinkBaseUri { get; set; } + + [JsonPropertyName("metalink-enable-unique-protocol")] + public string MetalinkEnableUniqueProtocol { get; set; } + + [JsonPropertyName("metalink-language")] + public string MetalinkLanguage { get; set; } + + [JsonPropertyName("metalink-location")] + public string MetalinkLocation { get; set; } + + [JsonPropertyName("metalink-os")] + public string MetalinkOs { get; set; } + + [JsonPropertyName("metalink-preferred-protocol")] + public string MetalinkPreferredProtocol { get; set; } + + [JsonPropertyName("metalink-version")] + public string MetalinkVersion { get; set; } + + [JsonPropertyName("min-split-size")] + public string MinSplitSize { get; set; } + + [JsonPropertyName("no-file-allocation-limit")] + public string NoFileAllocationLimit { get; set; } + + [JsonPropertyName("no-netrc")] + public string NoNetrc { get; set; } + + [JsonPropertyName("no-proxy")] + public string NoProxy { get; set; } + + [JsonPropertyName("out")] + public string Out { get; set; } + + [JsonPropertyName("parameterized-uri")] + public string ParameterizedUri { get; set; } + + [JsonPropertyName("pause")] + public string Pause { get; set; } + + [JsonPropertyName("pause-metadata")] + public string PauseMetadata { get; set; } + + [JsonPropertyName("piece-length")] + public string PieceLength { get; set; } + + [JsonPropertyName("proxy-method")] + public string ProxyMethod { get; set; } + + [JsonPropertyName("realtime-chunk-checksum")] + public string RealtimeChunkChecksum { get; set; } + + [JsonPropertyName("referer")] + public string Referer { get; set; } + + [JsonPropertyName("remote-time")] + public string RemoteTime { get; set; } + + [JsonPropertyName("remove-control-file")] + public string RemoveControlFile { get; set; } + + [JsonPropertyName("retry-wait")] + public string RetryWait { get; set; } + + [JsonPropertyName("reuse-uri")] + public string ReuseUri { get; set; } + + [JsonPropertyName("rpc-save-upload-metadata")] + public string RpcSaveUploadMetadata { get; set; } + + [JsonPropertyName("seed-ratio")] + public string SeedRatio { get; set; } + + [JsonPropertyName("seed-time")] + public string SeedTime { get; set; } + + [JsonPropertyName("select-file")] + public string SelectFile { get; set; } + + [JsonPropertyName("split")] + public string Split { get; set; } + + [JsonPropertyName("ssh-host-key-md")] + public string SshHostKeyMd { get; set; } + + [JsonPropertyName("stream-piece-selector")] + public string StreamPieceSelector { get; set; } + + [JsonPropertyName("timeout")] + public string Timeout { get; set; } + + [JsonPropertyName("uri-selector")] + public string UriSelector { get; set; } + + [JsonPropertyName("use-head")] + public string UseHead { get; set; } + + [JsonPropertyName("user-agent")] + public string UserAgent { get; set; } + } +} \ No newline at end of file diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaServer.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaServer.cs new file mode 100644 index 0000000..a107b72 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaServer.cs @@ -0,0 +1,32 @@ +/** + * This file is part of AriaNet by huming2207, licensed under the CC-BY-NC-SA 3.0 Australian Licence. + * You can find the original code in this GitHub repository: https://github.com/huming2207/AriaNet + */ + +using System.Collections.Generic; +using System.Text.Json.Serialization; + + +namespace AriaNet.Attributes +{ + public class ServerDetail + { + [JsonPropertyName("currentUri")] + public string CurrentUri { get; set; } + + [JsonPropertyName("downloadSpeed")] + public string DownloadSpeed { get; set; } + + [JsonPropertyName("uri")] + public string Uri { get; set; } + } + + public class AriaServer + { + [JsonPropertyName("index")] + public string Index { get; set; } + + [JsonPropertyName("servers")] + public List Servers { get; set; } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaSession.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaSession.cs new file mode 100644 index 0000000..9774da1 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaSession.cs @@ -0,0 +1,15 @@ +/** + * This file is part of AriaNet by huming2207, licensed under the CC-BY-NC-SA 3.0 Australian Licence. + * You can find the original code in this GitHub repository: https://github.com/huming2207/AriaNet + */ + +using System.Text.Json.Serialization; + +namespace AriaNet.Attributes +{ + public class AriaSession + { + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } + } +} \ No newline at end of file diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaStatus.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaStatus.cs new file mode 100644 index 0000000..28f568a --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaStatus.cs @@ -0,0 +1,53 @@ +/** + * This file is part of AriaNet by huming2207, licensed under the CC-BY-NC-SA 3.0 Australian Licence. + * You can find the original code in this GitHub repository: https://github.com/huming2207/AriaNet + */ + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace AriaNet.Attributes +{ + public class AriaStatus + { + + [JsonPropertyName("bitfield")] + public string Bitfield { get; set; } + + [JsonPropertyName("completedLength")] + public string CompletedLength { get; set; } + + [JsonPropertyName("connections")] + public string Connections { get; set; } + + [JsonPropertyName("dir")] + public string Dir { get; set; } + + [JsonPropertyName("downloadSpeed")] + public string DownloadSpeed { get; set; } + + [JsonPropertyName("files")] + public List Files { get; set; } + + [JsonPropertyName("gid")] + public string TaskId { get; set; } + + [JsonPropertyName("numPieces")] + public string NumPieces { get; set; } + + [JsonPropertyName("pieceLength")] + public string PieceLength { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } + + [JsonPropertyName("totalLength")] + public string TotalLength { get; set; } + + [JsonPropertyName("uploadLength")] + public string UploadLength { get; set; } + + [JsonPropertyName("uploadSpeed")] + public string UploadSpeed { get; set; } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaTorrent.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaTorrent.cs new file mode 100644 index 0000000..afd337f --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaTorrent.cs @@ -0,0 +1,39 @@ +/** + * This file is part of AriaNet by huming2207, licensed under the CC-BY-NC-SA 3.0 Australian Licence. + * You can find the original code in this GitHub repository: https://github.com/huming2207/AriaNet + */ + +using System.Text.Json.Serialization; + +namespace AriaNet.Attributes +{ + public class AriaTorrent + { + [JsonPropertyName("amChoking")] + public string AmChoking { get; set; } + + [JsonPropertyName("bitfield")] + public string BitField { get; set; } + + [JsonPropertyName("downloadSpeed")] + public string DownloadSpeed { get; set; } + + [JsonPropertyName("ip")] + public string Ip { get; set; } + + [JsonPropertyName("peerChoking")] + public string PeerChoking { get; set; } + + [JsonPropertyName("peerId")] + public string PeerId { get; set; } + + [JsonPropertyName("port")] + public string Port { get; set; } + + [JsonPropertyName("seeder")] + public string Seeder { get; set; } + + [JsonPropertyName("uploadSpeed")] + public string UploadSpeed { get; set; } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaUri.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaUri.cs new file mode 100644 index 0000000..2685644 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaUri.cs @@ -0,0 +1,18 @@ +/** + * This file is part of AriaNet by huming2207, licensed under the CC-BY-NC-SA 3.0 Australian Licence. + * You can find the original code in this GitHub repository: https://github.com/huming2207/AriaNet + */ + +using System.Text.Json.Serialization; + +namespace AriaNet.Attributes +{ + public class AriaUri + { + [JsonPropertyName("status")] + public string Status { get; set; } + + [JsonPropertyName("uri")] + public string Uri { get; set; } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaVersionInfo.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaVersionInfo.cs new file mode 100644 index 0000000..2a3e283 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/Attributes/AriaVersionInfo.cs @@ -0,0 +1,19 @@ +/** + * This file is part of AriaNet by huming2207, licensed under the CC-BY-NC-SA 3.0 Australian Licence. + * You can find the original code in this GitHub repository: https://github.com/huming2207/AriaNet + */ + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace AriaNet.Attributes +{ + public class AriaVersionInfo + { + [JsonPropertyName("enabledFeatures")] + public List EnabledFeatures { get; set; } + + [JsonPropertyName("version")] + public string Version { get; set; } + } +} \ No newline at end of file diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/JsonRpc/JsonRpcHttpClient.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/JsonRpc/JsonRpcHttpClient.cs new file mode 100644 index 0000000..751342e --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/JsonRpc/JsonRpcHttpClient.cs @@ -0,0 +1,44 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using System.Text.Json; +using Serilog; + +namespace XIVLauncher2.Common.Game.Patch.Acquisition.Aria.JsonRpc +{ + /// + /// Bodge JSON-RPC 2.0 http client implementation + /// + public class JsonRpcHttpClient + { + private readonly string _endpoint; + private readonly HttpClient _client; + + public JsonRpcHttpClient(string endpoint) + { + _endpoint = endpoint; + _client = new HttpClient + { + Timeout = new TimeSpan(0, 5, 0) + }; + } + + private static string Base64Encode(string plainText) { + var plainTextBytes = Encoding.UTF8.GetBytes(plainText); + return Convert.ToBase64String(plainTextBytes); + } + + public async Task Invoke(string method, params object[] args) + { + var argsJson = JsonSerializer.Serialize(args); + Log.Debug($"[JSONRPC] method({method}) arg({argsJson})"); + + var httpResponse = await _client.GetAsync(_endpoint + $"?method={method}&id={Guid.NewGuid()}¶ms={Base64Encode(argsJson)}"); + httpResponse.EnsureSuccessStatusCode(); + + var rpcResponse = JsonSerializer.Deserialize>(await httpResponse.Content.ReadAsStringAsync()); + return rpcResponse.Result; + } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/JsonRpc/JsonRpcResponse.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/JsonRpc/JsonRpcResponse.cs new file mode 100644 index 0000000..0150b5e --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/Aria/JsonRpc/JsonRpcResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace XIVLauncher2.Common.Game.Patch.Acquisition.Aria.JsonRpc +{ + public class JsonRpcResponse + { + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("jsonrpc")] + public string Version { get; set; } + + [JsonPropertyName("result")] + public T Result { get; set; } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/NetDownloaderPatchAcquisition.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/NetDownloaderPatchAcquisition.cs new file mode 100644 index 0000000..80466b1 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/NetDownloaderPatchAcquisition.cs @@ -0,0 +1,92 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Downloader; +using Serilog; + +namespace XIVLauncher2.Common.Game.Patch.Acquisition +{ + internal class NetDownloaderPatchAcquisition : PatchAcquisition + { + private readonly DirectoryInfo _patchStore; + private DownloadService _dlService; + + private string DownloadTempPath => Path.Combine(_patchStore.FullName, "temp"); + + private DownloadConfiguration _downloadOpt = new DownloadConfiguration + { + ParallelDownload = true, // download parts of file as parallel or not + BufferBlockSize = 8000, // usually, hosts support max to 8000 bytes + ChunkCount = 8, // file parts to download + MaxTryAgainOnFailover = int.MaxValue, // the maximum number of times to fail. + OnTheFlyDownload = false, // caching in-memory mode + Timeout = 10000, // timeout (millisecond) per stream block reader + TempDirectory = Path.GetTempPath(), // this is the library default + RequestConfiguration = new RequestConfiguration + { + UserAgent = Constants.PatcherUserAgent, + Accept = "*/*" + }, + //MaximumBytesPerSecond = App.Settings.SpeedLimitBytes / PatchManager.MAX_DOWNLOADS_AT_ONCE, + }; + + public NetDownloaderPatchAcquisition(DirectoryInfo patchStore, long maxBytesPerSecond) + { + this._patchStore = patchStore; + + this._downloadOpt.TempDirectory = this.DownloadTempPath; + } + + public override async Task StartDownloadAsync(string url, FileInfo outFile) + { + _dlService = new DownloadService(_downloadOpt); + + _dlService.DownloadProgressChanged += (sender, args) => + { + OnProgressChanged(new AcquisitionProgress + { + BytesPerSecondSpeed = (long) args.BytesPerSecondSpeed, + Progress = args.ReceivedBytesSize + }); + }; + + _dlService.DownloadFileCompleted += (sender, args) => + { + if (args.Error != null) + { + Log.Error(args.Error, "[WEB] Download failed for {0} with reason {1}", url, args.Error); + + // If we cancel downloads, we don't want to see an error message + if (args.Error is OperationCanceledException) + { + OnComplete(AcquisitionResult.Cancelled); + return; + } + + OnComplete(AcquisitionResult.Error); + return; + } + + if (args.Cancelled) + { + Log.Error("[WEB] Download cancelled for {0} with reason {1}", url, args.Error); + + /* + Cancellation should not produce an error message, since it is always triggered by another error or the user. + */ + OnComplete(AcquisitionResult.Cancelled); + return; + } + + OnComplete(AcquisitionResult.Success); + }; + + await _dlService.DownloadFileTaskAsync(url, outFile.FullName); + } + + public override async Task CancelAsync() + { + this._dlService.CancelAsync(); + } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/PatchAcquisition.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/PatchAcquisition.cs new file mode 100644 index 0000000..8f1b86b --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/PatchAcquisition.cs @@ -0,0 +1,26 @@ +using System; +using System.IO; +using System.Threading.Tasks; + +namespace XIVLauncher2.Common.Game.Patch.Acquisition +{ + public abstract class PatchAcquisition + { + public abstract Task StartDownloadAsync(string url, FileInfo outFile); + public abstract Task CancelAsync(); + + public event EventHandler ProgressChanged; + + protected void OnProgressChanged(AcquisitionProgress progress) + { + this.ProgressChanged?.Invoke(this, progress); + } + + public event EventHandler Complete; + + protected void OnComplete(AcquisitionResult result) + { + this.Complete?.Invoke(this, result); + } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/Acquisition/TorrentPatchAcquisition.cs b/src/XIVLauncher2.Common/Game/Patch/Acquisition/TorrentPatchAcquisition.cs new file mode 100644 index 0000000..cee3d93 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/Acquisition/TorrentPatchAcquisition.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using System.Net; +using System.Threading.Tasks; +using MonoTorrent.Client; +using Serilog; +using XIVLauncher2.Common.Game.Patch.PatchList; + +namespace XIVLauncher2.Common.Game.Patch.Acquisition +{ + public class TorrentPatchAcquisition : PatchAcquisition + { + private static ClientEngine torrentEngine; + + private TorrentManager _torrentManager; + private byte[] _torrentBytes; + + public static async Task InitializeAsync(long maxDownloadSpeed) + { + if (torrentEngine == null) + { + torrentEngine = new ClientEngine(); + + var builder = new EngineSettingsBuilder(torrentEngine.Settings) {MaximumDownloadSpeed = (int)maxDownloadSpeed}; + + await torrentEngine.UpdateSettingsAsync(builder.ToSettings()); + } + } + + public static async Task UnInitializeAsync() + { + if (torrentEngine != null) + { + await torrentEngine.StopAllAsync(); + torrentEngine = null; + } + } + + public bool IsApplicable(PatchListEntry patch) + { + try + { + using var client = new WebClient(); + + _torrentBytes = client.DownloadData("http://goaaats.github.io/patchtorrent/" + patch.GetUrlPath() + ".torrent"); + } + catch (Exception ex) + { + Log.Error(ex, $"[TORRENT] Could not get torrent for patch: {patch.GetUrlPath()}"); + return false; + } + + return true; + } + + public override async Task StartDownloadAsync(string url, FileInfo outFile) + { + throw new NotImplementedException("WIP"); + + /* + if (_torrentBytes == null) + { + if (!IsApplicable(patch)) + throw new Exception("This patch is not applicable to be downloaded with this acquisition method."); + } + + var torrent = await Torrent.LoadAsync(_torrentBytes); + var hasSignaledComplete = false; + + _torrentManager = await torrentEngine.AddAsync(torrent, outFile.Directory.FullName); + _torrentManager.TorrentStateChanged += async (sender, args) => + { + if ((int) _torrentManager.Progress == 100 && !hasSignaledComplete && args.NewState == TorrentState.Seeding) + { + OnComplete(AcquisitionResult.Success); + hasSignaledComplete = true; + await _torrentManager.StopAsync(); + } + }; + + _torrentManager.PieceHashed += (sender, args) => + { + OnProgressChanged(new AcquisitionProgress + { + Progress = _torrentManager.Monitor.DataBytesDownloaded, + BytesPerSecondSpeed = _torrentManager.Monitor.DownloadSpeed + }); + }; + + await _torrentManager.StartAsync(); + await _torrentManager.DhtAnnounceAsync(); + */ + } + + public override async Task CancelAsync() + { + if (_torrentManager == null) + return; + + await _torrentManager.StopAsync(); + await torrentEngine.RemoveAsync(_torrentManager); + } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/GamePatchType.cs b/src/XIVLauncher2.Common/Game/Patch/GamePatchType.cs new file mode 100644 index 0000000..acd5980 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/GamePatchType.cs @@ -0,0 +1,8 @@ +namespace XIVLauncher2.Common.Game.Patch +{ + public enum GamePatchType + { + Boot, + Game + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/NotEnoughSpaceException.cs b/src/XIVLauncher2.Common/Game/Patch/NotEnoughSpaceException.cs new file mode 100644 index 0000000..6c2f35d --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/NotEnoughSpaceException.cs @@ -0,0 +1,26 @@ +using System; + +namespace XIVLauncher2.Common.Game.Patch; + +public class NotEnoughSpaceException : Exception +{ + public enum SpaceKind + { + Patches, + AllPatches, + Game, + } + + public SpaceKind Kind { get; private set; } + + public long BytesRequired { get; set; } + + public long BytesFree { get; set; } + + public NotEnoughSpaceException(SpaceKind kind, long required, long free) + { + this.Kind = kind; + this.BytesRequired = required; + this.BytesFree = free; + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/PatchInstaller.cs b/src/XIVLauncher2.Common/Game/Patch/PatchInstaller.cs new file mode 100644 index 0000000..a674645 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/PatchInstaller.cs @@ -0,0 +1,167 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using Serilog; +using XIVLauncher2.Common.Game.Patch.PatchList; +using XIVLauncher2.Common.PatcherIpc; +using XIVLauncher2.Common.Patching; +using XIVLauncher2.Common.Patching.Rpc; +using XIVLauncher2.Common.Patching.Rpc.Implementations; + +namespace XIVLauncher2.Common.Game.Patch +{ + public class PatchInstaller : IDisposable + { + private readonly bool keepPatches; + private IRpc rpc; + + private RemotePatchInstaller? internalPatchInstaller; + + public enum InstallerState + { + NotStarted, + NotReady, + Ready, + Busy, + Failed + } + + public InstallerState State { get; private set; } = InstallerState.NotStarted; + + public event Action OnFail; + + public PatchInstaller(bool keepPatches) + { + this.keepPatches = keepPatches; + } + + public void StartIfNeeded(bool external = true) + { + var rpcName = "XLPatcher" + Guid.NewGuid().ToString(); + + Log.Information("[PATCHERIPC] Starting patcher with '{0}'", rpcName); + + if (external) + { + this.rpc = new SharedMemoryRpc(rpcName); + this.rpc.MessageReceived += RemoteCallHandler; + + var path = Path.Combine(AppContext.BaseDirectory, + "XIVLauncher.PatchInstaller.exe"); + + var startInfo = new ProcessStartInfo(path); + startInfo.UseShellExecute = true; + + //Start as admin if needed + if (!EnvironmentSettings.IsNoRunas && Environment.OSVersion.Version.Major >= 6) + startInfo.Verb = "runas"; + + startInfo.Arguments = $"rpc {rpcName}"; + + State = InstallerState.NotReady; + + try + { + Process.Start(startInfo); + } + catch (Exception ex) + { + Log.Error(ex, "Could not launch Patch Installer"); + throw new PatchInstallerException("Start failed.", ex); + } + } + else + { + this.rpc = new InProcessRpc(rpcName); + this.rpc.MessageReceived += RemoteCallHandler; + + this.internalPatchInstaller = new RemotePatchInstaller(new InProcessRpc(rpcName)); + this.internalPatchInstaller.Start(); + } + } + + private void RemoteCallHandler(PatcherIpcEnvelope envelope) + { + switch (envelope.OpCode) + { + case PatcherIpcOpCode.Hello: + //_client.Initialize(_clientPort); + Log.Information("[PATCHERIPC] GOT HELLO"); + State = InstallerState.Ready; + break; + + case PatcherIpcOpCode.InstallOk: + Log.Information("[PATCHERIPC] INSTALL OK"); + State = InstallerState.Ready; + break; + + case PatcherIpcOpCode.InstallFailed: + State = InstallerState.Failed; + OnFail?.Invoke(); + + Stop(); + Environment.Exit(0); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } + + public void WaitOnHello() + { + for (var i = 0; i < 40; i++) + { + if (State == InstallerState.Ready) + return; + + Thread.Sleep(500); + } + + throw new PatchInstallerException("Installer RPC timed out."); + } + + public void Stop() + { + if (State == InstallerState.NotReady || State == InstallerState.NotStarted || State == InstallerState.Busy) + return; + + this.rpc.SendMessage(new PatcherIpcEnvelope + { + OpCode = PatcherIpcOpCode.Bye + }); + } + + public void StartInstall(DirectoryInfo gameDirectory, FileInfo file, PatchListEntry patch, Repository repo) + { + State = InstallerState.Busy; + this.rpc.SendMessage(new PatcherIpcEnvelope + { + OpCode = PatcherIpcOpCode.StartInstall, + StartInstallInfo = new PatcherIpcStartInstall + { + GameDirectory = gameDirectory, + PatchFile = file, + Repo = repo, + VersionId = patch.VersionId, + KeepPatch = this.keepPatches, + } + }); + } + + public void FinishInstall(DirectoryInfo gameDirectory) + { + this.rpc.SendMessage(new PatcherIpcEnvelope + { + OpCode = PatcherIpcOpCode.Finish, + GameDirectory = gameDirectory + }); + } + + public void Dispose() + { + Stop(); + } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/PatchInstallerException.cs b/src/XIVLauncher2.Common/Game/Patch/PatchInstallerException.cs new file mode 100644 index 0000000..a908778 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/PatchInstallerException.cs @@ -0,0 +1,11 @@ +using System; + +namespace XIVLauncher2.Common.Game.Patch; + +public class PatchInstallerException : Exception +{ + public PatchInstallerException(string message, Exception? inner = null) : base(message, inner) + { + // ignored + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/PatchList/PatchListEntry.cs b/src/XIVLauncher2.Common/Game/Patch/PatchList/PatchListEntry.cs new file mode 100644 index 0000000..f6d19a4 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/PatchList/PatchListEntry.cs @@ -0,0 +1,33 @@ +using System.IO; +using System.Text.RegularExpressions; + +namespace XIVLauncher2.Common.Game.Patch.PatchList +{ + public class PatchListEntry + { + private static Regex urlRegex = new Regex(".*/((game|boot)/([a-zA-Z0-9]+)/.*)", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public string VersionId { get; set; } + public string HashType { get; set; } + public string Url { get; set; } + public long HashBlockSize { get; set; } + public string[] Hashes { get; set; } + public long Length { get; set; } + + public override string ToString() => $"{this.GetRepoName()}/{VersionId}"; + + private Match Deconstruct() => urlRegex.Match(this.Url); + + public string GetRepoName() + { + var name = this.Deconstruct().Groups[3].Captures[0].Value; + + // The URL doesn't have the "ffxiv" part for ffxiv repo. Let's fake it for readability. + return name == "4e9a232b" ? "ffxiv" : name; + } + + public string GetUrlPath() => this.Deconstruct().Groups[1].Captures[0].Value; + + public string GetFilePath() => GetUrlPath().Replace('/', Path.DirectorySeparatorChar); + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/PatchList/PatchListParseException.cs b/src/XIVLauncher2.Common/Game/Patch/PatchList/PatchListParseException.cs new file mode 100644 index 0000000..9295657 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/PatchList/PatchListParseException.cs @@ -0,0 +1,14 @@ +using System; + +namespace XIVLauncher2.Common.Game.Patch.PatchList; + +public class PatchListParseException : Exception +{ + public string List { get; private set; } + + public PatchListParseException(string list, Exception innerException) + : base("Failed to parse patch list", innerException) + { + List = list; + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/PatchList/PatchListParser.cs b/src/XIVLauncher2.Common/Game/Patch/PatchList/PatchListParser.cs new file mode 100644 index 0000000..7bc59e3 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/PatchList/PatchListParser.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace XIVLauncher2.Common.Game.Patch.PatchList +{ + class PatchListParser + { + public static PatchListEntry[] Parse(string list) + { + try + { + var lines = list.Split( + new[] { "\r\n", "\r", "\n", Environment.NewLine }, + StringSplitOptions.None + ); + + var output = new List(); + + for (var i = 5; i < lines.Length - 2; i++) + { + var fields = lines[i].Split('\t'); + output.Add(new PatchListEntry() + { + Length = long.Parse(fields[0]), + VersionId = fields[4], + HashType = fields[5], + + HashBlockSize = fields.Length == 9 ? long.Parse(fields[6]) : 0, + + // bootver patchlists don't have a hash field + Hashes = fields.Length == 9 ? (fields[7].Split(',')) : null, + Url = fields[fields.Length == 9 ? 8 : 5] + }); + } + + return output.ToArray(); + } + catch (Exception ex) + { + throw new PatchListParseException(list, ex); + } + } + } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/PatchManager.cs b/src/XIVLauncher2.Common/Game/Patch/PatchManager.cs new file mode 100644 index 0000000..312b552 --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/PatchManager.cs @@ -0,0 +1,524 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using XIVLauncher2.Common.Game.Patch.Acquisition; +using XIVLauncher2.Common.Game.Patch.Acquisition.Aria; +using XIVLauncher2.Common.Game.Patch.PatchList; +using XIVLauncher2.Common.Util; + +namespace XIVLauncher2.Common.Game.Patch +{ + public enum PatchState + { + Nothing, + IsDownloading, + Downloaded, + IsInstalling, + Finished + } + + public class PatchDownload + { + public PatchListEntry Patch { get; set; } + public PatchState State { get; set; } + } + + public class PatchManager + { + public const int MAX_DOWNLOADS_AT_ONCE = 4; + + private readonly CancellationTokenSource _cancelTokenSource = new(); + + private readonly AcquisitionMethod acquisitionMethod; + private readonly long speedLimitBytes; + private readonly Repository repo; + private readonly DirectoryInfo gamePath; + private readonly DirectoryInfo patchStore; + private readonly PatchInstaller installer; + private readonly Launcher launcher; + private readonly string sid; + + public readonly IReadOnlyList Downloads; + + public int CurrentInstallIndex { get; private set; } + + public enum SlotState + { + InProgress, + Checking, + Done, + } + + public readonly long[] Progresses = new long[MAX_DOWNLOADS_AT_ONCE]; + public readonly double[] Speeds = new double[MAX_DOWNLOADS_AT_ONCE]; + public readonly PatchDownload[] Actives = new PatchDownload[MAX_DOWNLOADS_AT_ONCE]; + public readonly SlotState[] Slots = new SlotState[MAX_DOWNLOADS_AT_ONCE]; + public readonly PatchAcquisition[] DownloadServices = new PatchAcquisition[MAX_DOWNLOADS_AT_ONCE]; + + public bool IsInstallerBusy { get; private set; } + + public bool DownloadsDone { get; private set; } + + public long AllDownloadsLength => GetDownloadLength(); + + private bool hasError = false; + + public event Action OnFail; + + public enum FailReason + { + DownloadProblem, + HashCheck, + } + + public PatchManager(AcquisitionMethod acquisitionMethod, long speedLimitBytes, Repository repo, IEnumerable patches, DirectoryInfo gamePath, DirectoryInfo patchStore, PatchInstaller installer, Launcher launcher, string sid) + { + Debug.Assert(patches != null, "patches != null ASSERTION FAILED"); + + this.acquisitionMethod = acquisitionMethod; + this.speedLimitBytes = speedLimitBytes; + this.repo = repo; + this.gamePath = gamePath; + this.patchStore = patchStore; + this.installer = installer; + this.launcher = launcher; + this.sid = sid; + + if (!this.patchStore.Exists) + this.patchStore.Create(); + + Downloads = patches.Select(patchListEntry => new PatchDownload {Patch = patchListEntry, State = PatchState.Nothing}).ToList().AsReadOnly(); + + // All dl slots are available at the start + for (var i = 0; i < MAX_DOWNLOADS_AT_ONCE; i++) + { + Slots[i] = SlotState.Done; + } + } + + public async Task PatchAsync(FileInfo aria2LogFile, bool external = true) + { + if (!EnvironmentSettings.IsIgnoreSpaceRequirements) + { + var freeSpaceDownload = PlatformHelpers.GetDiskFreeSpace(this.patchStore); + + if (Downloads.Any(x => x.Patch.Length > freeSpaceDownload)) + { + throw new NotEnoughSpaceException(NotEnoughSpaceException.SpaceKind.Patches, + Downloads.OrderByDescending(x => x.Patch.Length).First().Patch.Length, freeSpaceDownload); + } + + // If the first 6 patches altogether are bigger than the patch drive, we might run out of space + if (freeSpaceDownload < GetDownloadLength(6)) + { + throw new NotEnoughSpaceException(NotEnoughSpaceException.SpaceKind.AllPatches, AllDownloadsLength, + freeSpaceDownload); + } + + var freeSpaceGame = PlatformHelpers.GetDiskFreeSpace(this.gamePath); + + if (freeSpaceGame < AllDownloadsLength) + { + throw new NotEnoughSpaceException(NotEnoughSpaceException.SpaceKind.Game, AllDownloadsLength, + freeSpaceGame); + } + } + + this.installer.StartIfNeeded(external); + this.installer.WaitOnHello(); + + await InitializeAcquisition(aria2LogFile).ConfigureAwait(false); + + try + { + await Task.WhenAll(new Task[] { + Task.Run(RunDownloadQueue, _cancelTokenSource.Token), + Task.Run(RunApplyQueue, _cancelTokenSource.Token), + }).ConfigureAwait(false); + } + finally + { + // Only PatchManager uses Aria (or Torrent), so it's safe to shut it down here. + await UnInitializeAcquisition().ConfigureAwait(false); + } + } + + public async Task InitializeAcquisition(FileInfo aria2LogFile) + { + // TODO: Come up with a better pattern for initialization. This sucks. + switch (this.acquisitionMethod) + { + case AcquisitionMethod.NetDownloader: + // ignored + break; + + case AcquisitionMethod.MonoTorrentNetFallback: + await TorrentPatchAcquisition.InitializeAsync(this.speedLimitBytes / MAX_DOWNLOADS_AT_ONCE); + break; + + case AcquisitionMethod.MonoTorrentAriaFallback: + await AriaHttpPatchAcquisition.InitializeAsync(this.speedLimitBytes / MAX_DOWNLOADS_AT_ONCE, aria2LogFile); + await TorrentPatchAcquisition.InitializeAsync(this.speedLimitBytes / MAX_DOWNLOADS_AT_ONCE); + break; + + case AcquisitionMethod.Aria: + await AriaHttpPatchAcquisition.InitializeAsync(this.speedLimitBytes / MAX_DOWNLOADS_AT_ONCE, aria2LogFile); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } + + public static async Task UnInitializeAcquisition() + { + try + { + await AriaHttpPatchAcquisition.UnInitializeAsync(); + await TorrentPatchAcquisition.UnInitializeAsync(); + } + catch (Exception ex) + { + Log.Error(ex, "Could not uninitialize patch acquisition."); + } + } + + private async Task DownloadPatchAsync(PatchDownload download, int index) + { + var outFile = GetPatchFile(download.Patch); + + var realUrl = download.Patch.Url; + if (this.repo != Repository.Boot && false) // Disabled for now, waiting on SE to patch this + { + realUrl = await this.launcher.GenPatchToken(download.Patch.Url, this.sid); + } + + Log.Information("Downloading patch {0} at {1} to {2}", download.Patch.VersionId, realUrl, outFile.FullName); + + Actives[index] = download; + + if (outFile.Exists && CheckPatchValidity(download.Patch, outFile) == HashCheckResult.Pass) + { + download.State = PatchState.Downloaded; + Slots[index] = SlotState.Done; + Progresses[index] = download.Patch.Length; + return; + } + + PatchAcquisition acquisition; + + switch (this.acquisitionMethod) + { + case AcquisitionMethod.NetDownloader: + acquisition = new NetDownloaderPatchAcquisition(this.patchStore, this.speedLimitBytes / MAX_DOWNLOADS_AT_ONCE); + break; + + case AcquisitionMethod.MonoTorrentNetFallback: + acquisition = new TorrentPatchAcquisition(); + + var torrentAcquisition = acquisition as TorrentPatchAcquisition; + if (!torrentAcquisition.IsApplicable(download.Patch)) + acquisition = new NetDownloaderPatchAcquisition(this.patchStore, this.speedLimitBytes / MAX_DOWNLOADS_AT_ONCE); + break; + + case AcquisitionMethod.MonoTorrentAriaFallback: + acquisition = new TorrentPatchAcquisition(); + + torrentAcquisition = acquisition as TorrentPatchAcquisition; + if (!torrentAcquisition.IsApplicable(download.Patch)) + acquisition = new AriaHttpPatchAcquisition(); + break; + case AcquisitionMethod.Aria: + acquisition = new AriaHttpPatchAcquisition(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + + acquisition.ProgressChanged += (sender, args) => + { + Progresses[index] = args.Progress; + Speeds[index] = args.BytesPerSecondSpeed; + }; + + acquisition.Complete += (sender, args) => + { + if (args == AcquisitionResult.Error) + { + if (this.hasError) + return; + + Log.Error("Download failed for {0}", download.Patch.VersionId); + + hasError = true; + + OnFail?.Invoke(FailReason.DownloadProblem, download.Patch.VersionId); + + CancelAllDownloads(); + + Environment.Exit(0); + return; + } + + if (args == AcquisitionResult.Cancelled) + { + // Cancellation should not produce an error message, since it is always triggered by another error or the user. + Log.Error("Download cancelled for {0}", download.Patch.VersionId); + + return; + } + + // Indicate "Checking..." + Slots[index] = SlotState.Checking; + + var checkResult = CheckPatchValidity(download.Patch, outFile); + + // Let's just bail for now, need better handling of this later + if (checkResult != HashCheckResult.Pass) + { + if (this.hasError) + return; + + Log.Error("IsHashCheckPass failed with {Result} for {VersionId} after DL", checkResult, download.Patch.VersionId); + + hasError = true; + + OnFail?.Invoke(FailReason.HashCheck, download.Patch.VersionId); + + CancelAllDownloads(); + + outFile.Delete(); + Environment.Exit(0); + return; + } + + download.State = PatchState.Downloaded; + Slots[index] = SlotState.Done; + Progresses[index] = 0; + Speeds[index] = 0; + + Log.Information("Patch at {0} downloaded completely", download.Patch.Url); + + this.CheckIsDone(); + }; + + DownloadServices[index] = acquisition; + + await acquisition.StartDownloadAsync(realUrl, outFile); + } + + public void CancelAllDownloads() + { + #if !DEBUG + return; + #endif + + foreach (var downloadService in DownloadServices) + { + try + { + downloadService?.CancelAsync().GetAwaiter().GetResult(); + Thread.Sleep(200); + } + catch (Exception ex) + { + Log.Error(ex, "Could not cancel download."); + } + } + } + + private void RunDownloadQueue() + { + while (Downloads.Any(x => x.State == PatchState.Nothing)) + { + Thread.Sleep(500); + for (var i = 0; i < MAX_DOWNLOADS_AT_ONCE; i++) + { + if (Slots[i] != SlotState.Done) + continue; + + Slots[i] = SlotState.InProgress; + + var toDl = Downloads.FirstOrDefault(x => x.State == PatchState.Nothing); + + if (toDl == null) + return; + + toDl.State = PatchState.IsDownloading; + var curIndex = i; + Task.Run(async () => + { + try + { + await DownloadPatchAsync(toDl, curIndex); + } + catch (Exception ex) + { + Log.Error(ex, "Exception in DownloadPatchAsync"); + throw; + } + }); + } + } + } + + private void CheckIsDone() + { + Log.Information("CheckIsDone!!"); + + if (!Downloads.Any(x => x.State is PatchState.Nothing or PatchState.IsDownloading)) + { + Log.Information("All patches downloaded."); + + DownloadsDone = true; + + for (var j = 0; j < Progresses.Length; j++) + { + Progresses[j] = 0; + } + + for (var j = 0; j < Speeds.Length; j++) + { + Speeds[j] = 0; + } + + return; + } + } + + private void RunApplyQueue() + { + while (CurrentInstallIndex < Downloads.Count) + { + Thread.Sleep(500); + + var toInstall = Downloads[CurrentInstallIndex]; + + if (toInstall.State != PatchState.Downloaded) + continue; + + toInstall.State = PatchState.IsInstalling; + + Log.Information("Starting patch install for {0} at {1}({2})", toInstall.Patch.VersionId, toInstall.Patch.Url, CurrentInstallIndex); + + IsInstallerBusy = true; + + this.installer.StartInstall(this.gamePath, GetPatchFile(toInstall.Patch), toInstall.Patch, GetRepoForPatch(toInstall.Patch)); + + while (this.installer.State != PatchInstaller.InstallerState.Ready) + { + Thread.Yield(); + } + + // TODO need to handle this better + if (this.installer.State == PatchInstaller.InstallerState.Failed) + return; + + Log.Information($"Patch at {CurrentInstallIndex} installed"); + + IsInstallerBusy = false; + + toInstall.State = PatchState.Finished; + CurrentInstallIndex++; + } + + Log.Information("PATCHING finish"); + this.installer.FinishInstall(this.gamePath); + } + + private enum HashCheckResult + { + Pass, + BadHash, + BadLength, + } + + private static HashCheckResult CheckPatchValidity(PatchListEntry patchListEntry, FileInfo path) + { + if (patchListEntry.HashType != "sha1") + { + Log.Error("??? Unknown HashType: {0} for {1}", patchListEntry.HashType, patchListEntry.Url); + return HashCheckResult.Pass; + } + + var stream = path.OpenRead(); + + if (stream.Length != patchListEntry.Length) + { + return HashCheckResult.BadLength; + } + + var parts = (int) Math.Ceiling((double) patchListEntry.Length / patchListEntry.HashBlockSize); + var block = new byte[patchListEntry.HashBlockSize]; + + for (var i = 0; i < parts; i++) + { + var read = stream.Read(block, 0, (int) patchListEntry.HashBlockSize); + + if (read < patchListEntry.HashBlockSize) + { + var trimmedBlock = new byte[read]; + Array.Copy(block, 0, trimmedBlock, 0, read); + block = trimmedBlock; + } + + using var sha1 = new SHA1Managed(); + + var hash = sha1.ComputeHash(block); + var sb = new StringBuilder(hash.Length * 2); + + foreach (var b in hash) + { + sb.Append(b.ToString("x2")); + } + + if (sb.ToString() == patchListEntry.Hashes[i]) + continue; + + stream.Close(); + return HashCheckResult.BadHash; + } + + stream.Close(); + return HashCheckResult.Pass; + } + + private FileInfo GetPatchFile(PatchListEntry patch) + { + var file = new FileInfo(Path.Combine(this.patchStore.FullName, patch.GetFilePath())); + file.Directory.Create(); + + return file; + } + + private Repository GetRepoForPatch(PatchListEntry patch) + { + if (patch.Url.Contains("boot")) + return Repository.Boot; + + if (patch.Url.Contains("ex1")) + return Repository.Ex1; + + if (patch.Url.Contains("ex2")) + return Repository.Ex2; + + if (patch.Url.Contains("ex3")) + return Repository.Ex3; + + if (patch.Url.Contains("ex4")) + return Repository.Ex4; + + return Repository.Ffxiv; + } + + private long GetDownloadLength() => GetDownloadLength(Downloads.Count); + + private long GetDownloadLength(int takeAmount) => Downloads.Take(takeAmount).Where(x => x.State == PatchState.Nothing || x.State == PatchState.IsDownloading).Sum(x => x.Patch.Length) - Progresses.Sum(); } +} diff --git a/src/XIVLauncher2.Common/Game/Patch/PatchVerifier.cs b/src/XIVLauncher2.Common/Game/Patch/PatchVerifier.cs new file mode 100644 index 0000000..5b29f8a --- /dev/null +++ b/src/XIVLauncher2.Common/Game/Patch/PatchVerifier.cs @@ -0,0 +1,666 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json; +using System.Text.Json.Serialization; +using Serilog; +using XIVLauncher2.Common.Game.Exceptions; +using XIVLauncher2.Common.Patching.IndexedZiPatch; +using XIVLauncher2.Common.Patching.Util; +using XIVLauncher2.Common.PlatformAbstractions; + +namespace XIVLauncher2.Common.Game.Patch +{ + public class PatchVerifier : IDisposable + { + private const string REPAIR_RECYCLER_DIRECTORY = "repair_recycler"; + + private static readonly Regex[] GameIgnoreUnnecessaryFilePatterns = new Regex[] + { + // Base game version files. + new Regex(@"^ffxivgame\.(?:bck|ver)$", RegexOptions.IgnoreCase), + + // Expansion version files. + new Regex(@"^sqpack/ex([1-9][0-9]*)/ex\1\.(?:bck|ver)$", RegexOptions.IgnoreCase), + + // Under WINE, since .dat files are actually WMV videos, the game will become unusable. + // Bink videos will be used instead in those cases. + new Regex(@"^movie/ffxiv/0000[0-3]\.bk2$", RegexOptions.IgnoreCase), + + // DXVK can deal with corrupted cache files by itself, so let it do the job by itself. + new Regex(@"^ffxiv_dx11\.dxvk-cache$", RegexOptions.IgnoreCase), + + // Repair recycle bin folder. + new Regex(@"^repair_recycler/.*$", RegexOptions.IgnoreCase), + + // Ignore gshade folders. Unless someone wants to handle the symlinked folder, just skip recycling them. + new Regex(@"^gshade-(shader|preset)s$", RegexOptions.IgnoreCase), + }; + + private readonly ISettings _settings; + private readonly int _maxExpansionToCheck; + private readonly bool _external; + private HttpClient _client; + private CancellationTokenSource _cancellationTokenSource = new(); + + private Dictionary _repoMetaPaths = new(); + private Dictionary _patchSources = new(); + + private Task _verificationTask; + private List> _reportedProgresses = new(); + + public int ProgressUpdateInterval { get; private set; } + public int NumBrokenFiles { get; private set; } = 0; + public string MovedFileToDir { get; private set; } = null; + public List MovedFiles { get; private set; } = new(); + public int PatchSetIndex { get; private set; } + public int PatchSetCount { get; private set; } + public int TaskIndex { get; private set; } + public long Progress { get; private set; } + public long Total { get; private set; } + public int TaskCount { get; private set; } + public IndexedZiPatchInstaller.InstallTaskState CurrentMetaInstallState { get; private set; } = IndexedZiPatchInstaller.InstallTaskState.NotStarted; + public string CurrentFile { get; private set; } + public long Speed { get; private set; } + public Exception LastException { get; private set; } + + private const string BASE_URL = "https://raw.githubusercontent.com/goatcorp/patchinfo/main/"; + + public enum VerifyState + { + NotStarted, + DownloadMeta, + VerifyAndRepair, + Done, + Cancelled, + Error + } + + private struct PatchSource + { + public FileInfo FileInfo; + public Uri Uri; + } + + private class VerifyVersions + { + [JsonPropertyName("boot")] + public string Boot { get; set; } + + [JsonPropertyName("bootRevision")] + public int BootRevision { get; set; } + + [JsonPropertyName("game")] + public string Game { get; set; } + + [JsonPropertyName("gameRevision")] + public int GameRevision { get; set; } + + [JsonPropertyName("ex1")] + public string Ex1 { get; set; } + + [JsonPropertyName("ex1Revision")] + public int Ex1Revision { get; set; } + + [JsonPropertyName("ex2")] + public string Ex2 { get; set; } + + [JsonPropertyName("ex2Revision")] + public int Ex2Revision { get; set; } + + [JsonPropertyName("ex3")] + public string Ex3 { get; set; } + + [JsonPropertyName("ex3Revision")] + public int Ex3Revision { get; set; } + + [JsonPropertyName("ex4")] + public string Ex4 { get; set; } + + [JsonPropertyName("ex4Revision")] + public int Ex4Revision { get; set; } + } + + public VerifyState State { get; private set; } = VerifyState.NotStarted; + + public PatchVerifier(ISettings settings, Launcher.LoginResult loginResult, int progressUpdateInterval, int maxExpansion, bool external = true) + { + this._settings = settings; + _client = new HttpClient(); + ProgressUpdateInterval = progressUpdateInterval; + _maxExpansionToCheck = maxExpansion; + _external = external; + + SetLoginState(loginResult); + } + + public void Start() + { + Debug.Assert(_patchSources.Count != 0); + Debug.Assert(_verificationTask == null || _verificationTask.IsCompleted); + + _cancellationTokenSource = new(); + _reportedProgresses.Clear(); + NumBrokenFiles = 0; + PatchSetIndex = 0; + PatchSetCount = 0; + TaskIndex = 0; + Progress = 0; + Total = 0; + TaskCount = 0; + CurrentFile = null; + Speed = 0; + CurrentMetaInstallState = IndexedZiPatchInstaller.InstallTaskState.NotStarted; + LastException = null; + + _verificationTask = Task.Run(this.RunVerifier, _cancellationTokenSource.Token); + } + + public Task Cancel() + { + _cancellationTokenSource.Cancel(); + return WaitForCompletion(); + } + + public Task WaitForCompletion() + { + return _verificationTask ?? Task.CompletedTask; + } + + private void SetLoginState(Launcher.LoginResult result) + { + _patchSources.Clear(); + + foreach (var patch in result.PendingPatches) + { + var repoName = patch.GetRepoName(); + if (repoName == "ffxiv") + repoName = "ex0"; + + _patchSources.Add($"{repoName}:{Path.GetFileName(patch.GetFilePath())}", new PatchSource() + { + FileInfo = new FileInfo(Path.Combine(_settings.PatchPath.FullName, patch.GetFilePath())), + Uri = new Uri(patch.Url), + }); + } + } + + private bool AdminAccessRequired(string gameRootPath) + { + string tempFn; + do + { + tempFn = Path.Combine(gameRootPath, Guid.NewGuid().ToString()); + } while (File.Exists(tempFn)); + try + { + File.WriteAllText(tempFn, ""); + File.Delete(tempFn); + } + catch (UnauthorizedAccessException) + { + return true; + } + return false; + } + + private void RecordProgressForEstimation() + { + var now = DateTime.Now.Ticks; + _reportedProgresses.Add(Tuple.Create(now, Progress)); + while ((now - _reportedProgresses.First().Item1) > 10 * 1000 * 8000) + _reportedProgresses.RemoveAt(0); + + var elapsedMs = _reportedProgresses.Last().Item1 - _reportedProgresses.First().Item1; + if (elapsedMs == 0) + Speed = 0; + else + Speed = (_reportedProgresses.Last().Item2 - _reportedProgresses.First().Item2) * 10 * 1000 * 1000 / elapsedMs; + } + + public async Task MoveUnnecessaryFiles(IIndexedZiPatchIndexInstaller installer, string gamePath, HashSet targetRelativePaths) + { + this.MovedFileToDir = Path.Combine(gamePath, REPAIR_RECYCLER_DIRECTORY, DateTime.Now.ToString("yyyyMMdd_HHmmss")); + + var rootPathInfo = new DirectoryInfo(gamePath); + gamePath = rootPathInfo.FullName; + + Queue directoriesToVisit = new(); + HashSet directoriesVisited = new(); + directoriesToVisit.Enqueue(rootPathInfo); + directoriesVisited.Add(rootPathInfo); + + while (directoriesToVisit.Any()) + { + var dir = directoriesToVisit.Dequeue(); + + // For directories, ignore if final path does not belong in the root path. + if (!dir.FullName.ToLowerInvariant().Replace('\\', '/').StartsWith(gamePath.ToLowerInvariant().Replace('\\', '/'))) + continue; + + var relativeDirPath = dir == rootPathInfo ? "" : dir.FullName.Substring(gamePath.Length + 1).Replace('\\', '/'); + if (GameIgnoreUnnecessaryFilePatterns.Any(x => x.IsMatch(relativeDirPath))) + continue; + + if (!dir.EnumerateFileSystemInfos().Any()) + { + await installer.RemoveDirectory(dir.FullName); + await installer.CreateDirectory(Path.Combine(this.MovedFileToDir, relativeDirPath)); + continue; + } + + foreach (var subdir in dir.EnumerateDirectories()) + { + if (directoriesVisited.Contains(subdir)) + continue; + + directoriesVisited.Add(subdir); + directoriesToVisit.Enqueue(subdir); + } + + foreach (var file in dir.EnumerateFiles()) + { + if (!file.FullName.ToLowerInvariant().Replace('\\', '/').StartsWith(gamePath.ToLowerInvariant().Replace('\\', '/'))) + continue; + + var relativePath = file.FullName.Substring(gamePath.Length + 1).Replace('\\', '/'); + if (targetRelativePaths.Any(x => x.Replace('\\', '/').ToLowerInvariant() == relativePath.ToLowerInvariant())) + continue; + + if (GameIgnoreUnnecessaryFilePatterns.Any(x => x.IsMatch(relativePath))) + continue; + + await installer.MoveFile(file.FullName, Path.Combine(this.MovedFileToDir, relativePath)); + MovedFiles.Add(relativePath); + } + } + } + + private async Task RunVerifier() + { + State = VerifyState.NotStarted; + LastException = null; + IIndexedZiPatchIndexInstaller indexedZiPatchIndexInstaller = null; + try + { + var assemblyLocation = AppContext.BaseDirectory; + if (_external) + indexedZiPatchIndexInstaller = new IndexedZiPatchIndexRemoteInstaller(Path.Combine(assemblyLocation!, "XIVLauncher.PatchInstaller.exe"), + AdminAccessRequired(_settings.GamePath.FullName)); + else + indexedZiPatchIndexInstaller = new IndexedZiPatchIndexLocalInstaller(); + + await indexedZiPatchIndexInstaller.SetWorkerProcessPriority(ProcessPriorityClass.Idle).ConfigureAwait(false); + + while (!_cancellationTokenSource.IsCancellationRequested && State != VerifyState.Done) + { + switch (State) + { + + case VerifyState.NotStarted: + State = VerifyState.DownloadMeta; + break; + + case VerifyState.DownloadMeta: + await this.GetPatchMeta().ConfigureAwait(false); + State = VerifyState.VerifyAndRepair; + break; + + case VerifyState.VerifyAndRepair: + Debug.Assert(_repoMetaPaths.Count != 0); + + const int MAX_CONCURRENT_CONNECTIONS_FOR_PATCH_SET = 8; + const int REATTEMPT_COUNT = 5; + + CurrentFile = null; + TaskIndex = 0; + PatchSetIndex = 0; + PatchSetCount = _repoMetaPaths.Count; + Progress = Total = 0; + + HashSet targetRelativePaths = new(); + + var bootPath = Path.Combine(_settings.GamePath.FullName, "boot"); + var gamePath = Path.Combine(_settings.GamePath.FullName, "game"); + + foreach (var metaPath in _repoMetaPaths) + { + var patchIndex = new IndexedZiPatchIndex(new BinaryReader(new DeflateStream(new FileStream(metaPath.Value, FileMode.Open, FileAccess.Read), CompressionMode.Decompress))); + var adjustedGamePath = patchIndex.ExpacVersion == IndexedZiPatchIndex.EXPAC_VERSION_BOOT ? bootPath : gamePath; + + foreach (var target in patchIndex.Targets) + targetRelativePaths.Add(target.RelativePath); + + void UpdateVerifyProgress(int targetIndex, long progress, long max) + { + CurrentFile = patchIndex[Math.Min(targetIndex, patchIndex.Length - 1)].RelativePath; + TaskIndex = targetIndex; + Progress = Math.Min(progress, max); + Total = max; + RecordProgressForEstimation(); + } + + void UpdateInstallProgress(int sourceIndex, long progress, long max, IndexedZiPatchInstaller.InstallTaskState state) + { + CurrentFile = patchIndex.Sources[Math.Min(sourceIndex, patchIndex.Sources.Count - 1)]; + TaskIndex = sourceIndex; + Progress = Math.Min(progress, max); + Total = max; + CurrentMetaInstallState = state; + RecordProgressForEstimation(); + } + + try + { + indexedZiPatchIndexInstaller.OnVerifyProgress += UpdateVerifyProgress; + indexedZiPatchIndexInstaller.OnInstallProgress += UpdateInstallProgress; + await indexedZiPatchIndexInstaller.ConstructFromPatchFile(patchIndex, ProgressUpdateInterval).ConfigureAwait(false); + + var fileBroken = new bool[patchIndex.Length].ToList(); + var repaired = false; + for (var attemptIndex = 0; attemptIndex < REATTEMPT_COUNT; attemptIndex++) + { + CurrentMetaInstallState = IndexedZiPatchInstaller.InstallTaskState.NotStarted; + + TaskCount = patchIndex.Length; + Progress = Total = TaskIndex = 0; + _reportedProgresses.Clear(); + + await indexedZiPatchIndexInstaller.SetTargetStreamsFromPathReadOnly(adjustedGamePath).ConfigureAwait(false); + // TODO: check one at a time if random access is slow? + await indexedZiPatchIndexInstaller.VerifyFiles(attemptIndex > 0, Environment.ProcessorCount, _cancellationTokenSource.Token).ConfigureAwait(false); + + var missingPartIndicesPerTargetFile = await indexedZiPatchIndexInstaller.GetMissingPartIndicesPerTargetFile().ConfigureAwait(false); + if ((repaired = missingPartIndicesPerTargetFile.All(x => !x.Any()))) + break; + else if (attemptIndex == 1) + Log.Warning("One or more of local copies of patch files seem to be corrupt, if any. Ignoring local patch files for further attempts."); + + for (var i = 0; i < missingPartIndicesPerTargetFile.Count; i++) + if (missingPartIndicesPerTargetFile[i].Any()) + fileBroken[i] = true; + + TaskCount = patchIndex.Sources.Count; + Progress = Total = TaskIndex = 0; + _reportedProgresses.Clear(); + var missing = await indexedZiPatchIndexInstaller.GetMissingPartIndicesPerPatch().ConfigureAwait(false); + + await indexedZiPatchIndexInstaller.SetTargetStreamsFromPathReadWriteForMissingFiles(adjustedGamePath).ConfigureAwait(false); + var prefix = patchIndex.ExpacVersion == IndexedZiPatchIndex.EXPAC_VERSION_BOOT ? "boot:" : $"ex{patchIndex.ExpacVersion}:"; + for (var i = 0; i < patchIndex.Sources.Count; i++) + { + var patchSourceKey = prefix + patchIndex.Sources[i]; + + if (!missing[i].Any()) + continue; + else + Log.Information("Looking for patch file {0} (key: \"{1}\")", patchIndex.Sources[i], patchSourceKey); + + if (!_patchSources.TryGetValue(patchSourceKey, out var source)) + throw new InvalidOperationException($"Key \"{patchSourceKey}\" not found in _patchSources"); + + // We might be trying again because local copy of the patch file might be corrupt, so refer to the local copy only for the first attempt. + if (attemptIndex == 0 && source.FileInfo.Exists) + await indexedZiPatchIndexInstaller.QueueInstall(i, source.FileInfo, MAX_CONCURRENT_CONNECTIONS_FOR_PATCH_SET).ConfigureAwait(false); + else + await indexedZiPatchIndexInstaller.QueueInstall(i, source.Uri, null, MAX_CONCURRENT_CONNECTIONS_FOR_PATCH_SET).ConfigureAwait(false); + } + + CurrentMetaInstallState = IndexedZiPatchInstaller.InstallTaskState.Connecting; + try + { + await indexedZiPatchIndexInstaller.Install(MAX_CONCURRENT_CONNECTIONS_FOR_PATCH_SET, _cancellationTokenSource.Token).ConfigureAwait(false); + } + catch (Exception e) + { + Log.Error(e, "IndexedZiPatchIndexInstaller.Install"); + if (attemptIndex == REATTEMPT_COUNT - 1) + throw; + } + } + + if (!repaired) + throw new IOException($"Failed to repair after {REATTEMPT_COUNT} attempts"); + + await indexedZiPatchIndexInstaller.WriteVersionFiles(adjustedGamePath).ConfigureAwait(false); + + NumBrokenFiles += fileBroken.Count(x => x); + PatchSetIndex++; + } + finally + { + indexedZiPatchIndexInstaller.OnVerifyProgress -= UpdateVerifyProgress; + indexedZiPatchIndexInstaller.OnInstallProgress -= UpdateInstallProgress; + } + } + + await MoveUnnecessaryFiles(indexedZiPatchIndexInstaller, gamePath, targetRelativePaths); + + State = VerifyState.Done; + break; + + case VerifyState.Done: + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } + } + catch (Exception ex) + { + if (ex is OperationCanceledException) + State = VerifyState.Cancelled; + else if (_cancellationTokenSource.IsCancellationRequested) + State = VerifyState.Cancelled; + else if (ex is Win32Exception winex && (uint)winex.HResult == 0x80004005u) // The operation was canceled by the user (UAC dialog cancellation) + State = VerifyState.Cancelled; + else + { + Log.Error(ex, "Unexpected error occurred in RunVerifier"); + Log.Information("_patchSources had following:"); + foreach (var kvp in _patchSources) + { + Log.Information("* \"{0}\" = {1} / {2}({3})", kvp.Key, kvp.Value.Uri.ToString(), kvp.Value.FileInfo.FullName, kvp.Value.FileInfo.Exists ? "Exists" : "Nonexistent"); + } + + LastException = ex; + State = VerifyState.Error; + } + } + finally + { + indexedZiPatchIndexInstaller?.Dispose(); + } + } + + private async Task GetPatchMeta() + { + PatchSetCount = 6; + PatchSetIndex = 0; + + _repoMetaPaths.Clear(); + + var metaFolder = Path.Combine(Paths.RoamingPath, "patchMeta"); + Directory.CreateDirectory(metaFolder); + + CurrentFile = "latest.json"; + Total = Progress = 0; + + var latestVersionJson = await _client.GetStringAsync(BASE_URL + "latest.json").ConfigureAwait(false); + _cancellationTokenSource.Token.ThrowIfCancellationRequested(); + + var latestVersion = JsonSerializer.Deserialize(latestVersionJson); + + PatchSetIndex++; + await this.GetRepoMeta(Repository.Ffxiv, latestVersion.Game, metaFolder, latestVersion.GameRevision).ConfigureAwait(false); + _cancellationTokenSource.Token.ThrowIfCancellationRequested(); + + PatchSetIndex++; + if (_maxExpansionToCheck >= 1) + await this.GetRepoMeta(Repository.Ex1, latestVersion.Ex1, metaFolder, latestVersion.Ex1Revision).ConfigureAwait(false); + _cancellationTokenSource.Token.ThrowIfCancellationRequested(); + + PatchSetIndex++; + if (_maxExpansionToCheck >= 2) + await this.GetRepoMeta(Repository.Ex2, latestVersion.Ex2, metaFolder, latestVersion.Ex2Revision).ConfigureAwait(false); + _cancellationTokenSource.Token.ThrowIfCancellationRequested(); + + PatchSetIndex++; + if (_maxExpansionToCheck >= 3) + await this.GetRepoMeta(Repository.Ex3, latestVersion.Ex3, metaFolder, latestVersion.Ex3Revision).ConfigureAwait(false); + _cancellationTokenSource.Token.ThrowIfCancellationRequested(); + + PatchSetIndex++; + if (_maxExpansionToCheck >= 4) + await this.GetRepoMeta(Repository.Ex4, latestVersion.Ex4, metaFolder, latestVersion.Ex4Revision).ConfigureAwait(false); + _cancellationTokenSource.Token.ThrowIfCancellationRequested(); + + PatchSetIndex++; + } + + private async Task GetRepoMeta(Repository repo, string latestVersion, string baseDir, int patchIndexFileRevision) + { + _reportedProgresses.Clear(); + CurrentFile = latestVersion; + Total = 32 * 1048576; + Progress = 0; + + var version = repo.GetVer(_settings.GamePath); + + // TODO: We should not assume that this always has a "D". We should just store them by the patchlist VersionId instead. + var repoShorthand = repo == Repository.Ffxiv ? "game" : repo.ToString().ToLower(); + var fileName = $"{latestVersion}.patch.index"; + + var metaPath = Path.Combine(baseDir, repoShorthand); + var filePath = Path.Combine(metaPath, fileName) + (patchIndexFileRevision > 0 ? $".v{patchIndexFileRevision}" : ""); + Directory.CreateDirectory(metaPath); + + if (!File.Exists(filePath)) + { + var request = await _client.GetAsync($"{BASE_URL}{repoShorthand}/{fileName}", HttpCompletionOption.ResponseHeadersRead, _cancellationTokenSource.Token).ConfigureAwait(false); + if (request.StatusCode == HttpStatusCode.NotFound) + throw new NoVersionReferenceException(repo, latestVersion); + + request.EnsureSuccessStatusCode(); + + Total = request.Content.Headers.ContentLength.GetValueOrDefault(Total); + + var tempFile = new FileInfo(filePath + ".tmp"); + var complete = false; + + try + { + using var sourceStream = await request.Content.ReadAsStreamAsync().ConfigureAwait(false); + using var buffer = ReusableByteBufferManager.GetBuffer(); + + using (var targetStream = tempFile.OpenWrite()) + { + while (true) + { + _cancellationTokenSource.Token.ThrowIfCancellationRequested(); + + int read = await sourceStream.ReadAsync(buffer.Buffer, 0, buffer.Buffer.Length, _cancellationTokenSource.Token).ConfigureAwait(false); + if (read == 0) + break; + + Total = Math.Max(Total, Progress + read); + Progress += read; + RecordProgressForEstimation(); + await targetStream.WriteAsync(buffer.Buffer, 0, read, _cancellationTokenSource.Token).ConfigureAwait(false); + } + } + complete = true; + } + finally + { + if (complete) + tempFile.MoveTo(filePath); + else + { + try + { + if (tempFile.Exists) + tempFile.Delete(); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to delete temp file at {0}", tempFile.FullName); + } + } + } + } + + _repoMetaPaths.Add(repo, filePath); + Log.Verbose("Downloaded patch index for {Repo}({Version})", repo, latestVersion); + } + + public static List GetRelevantFiles(string gamePath) + { + var rootPathInfo = new DirectoryInfo(gamePath); + gamePath = rootPathInfo.FullName; + + Queue directoriesToVisit = new(); + HashSet directoriesVisited = new(); + directoriesToVisit.Enqueue(rootPathInfo); + directoriesVisited.Add(rootPathInfo); + + List files = new(); + + while (directoriesToVisit.Any()) + { + var dir = directoriesToVisit.Dequeue(); + + // For directories, ignore if final path does not belong in the root path. + if (!dir.FullName.ToLowerInvariant().Replace('\\', '/').StartsWith(gamePath.ToLowerInvariant().Replace('\\', '/'), StringComparison.Ordinal)) + continue; + + var relativeDirPath = dir == rootPathInfo ? "" : dir.FullName.Substring(gamePath.Length + 1).Replace('\\', '/'); + if (GameIgnoreUnnecessaryFilePatterns.Any(x => x.IsMatch(relativeDirPath))) + continue; + + foreach (var subdir in dir.EnumerateDirectories()) + { + if (directoriesVisited.Contains(subdir)) + continue; + + directoriesVisited.Add(subdir); + directoriesToVisit.Enqueue(subdir); + } + + foreach (var file in dir.EnumerateFiles()) + { + if (!file.FullName.ToLowerInvariant().Replace('\\', '/').StartsWith(gamePath.ToLowerInvariant().Replace('\\', '/'), StringComparison.Ordinal)) + continue; + + var relativePath = file.FullName.Substring(gamePath.Length + 1).Replace('\\', '/'); + + if (GameIgnoreUnnecessaryFilePatterns.Any(x => x.IsMatch(relativePath))) + continue; + + files.Add(file); + } + } + + return files; + } + + public void Dispose() + { + if (_verificationTask != null && !_verificationTask.IsCompleted) + { + _cancellationTokenSource.Cancel(); + _verificationTask.Wait(); + } + } + } +} diff --git a/src/XIVLauncher2.Common/Http/HttpServer.cs b/src/XIVLauncher2.Common/Http/HttpServer.cs new file mode 100644 index 0000000..b0c574a --- /dev/null +++ b/src/XIVLauncher2.Common/Http/HttpServer.cs @@ -0,0 +1,90 @@ +using System; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; + +namespace XIVLauncher2.Common.Http +{ + // This is a very dumb HTTP server that just accepts GETs and fires events with the requested URL + internal class HttpServer + { + private readonly TcpListener listener; + + private readonly byte[] httpResponse; + + public EventHandler GetReceived; + + private bool _isRunning = false; + + public class HttpServerGetEvent + { + public string Path { get; set; } + } + + public HttpServer(int port, string version) + { + this.listener = new TcpListener(IPAddress.Any, port); + + this.httpResponse = Encoding.Default.GetBytes( + "HTTP/1.0 200 OK\n" + + "Content-Type: application/json; charset=UTF-8\n" + + "\n{\"app\":\"XIVLauncher\", \"version\":\"" + version + "\"}" + ); + } + + public void Start() + { + try + { + this.listener.Start(); + _isRunning = true; + + while (_isRunning) + { + if (!this.listener.Pending()) + { + Thread.Sleep(200); + continue; + } + + var client = this.listener.AcceptTcpClient(); + + while (client.Connected) + { + var networkStream = client.GetStream(); + + var message = new byte[1024]; + networkStream.Read(message, 0, message.Length); + + var messageString = Encoding.Default.GetString(message); + Debug.WriteLine(Encoding.Default.GetString(message)); + + networkStream.Write(httpResponse, 0, httpResponse.Length); + + networkStream.Close(3); + + GetReceived?.Invoke(this, new HttpServerGetEvent + { + Path = Regex.Match(messageString, "GET (?.+) HTTP").Groups["url"].Value + }); + } + + client.Close(); + } + } + catch + { + // ignored + } + } + + public void Stop() + { + _isRunning = false; + this.listener.Stop(); + } + } +} diff --git a/src/XIVLauncher2.Common/Http/OtpListener.cs b/src/XIVLauncher2.Common/Http/OtpListener.cs new file mode 100644 index 0000000..847dd90 --- /dev/null +++ b/src/XIVLauncher2.Common/Http/OtpListener.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; + +namespace XIVLauncher2.Common.Http +{ + public class OtpListener + { + private volatile HttpServer server; + + private const int HTTP_PORT = 4646; + + public event LoginEvent OnOtpReceived; + + public delegate void LoginEvent(string onetimePassword); + + private readonly Thread serverThread; + + public OtpListener(string version) + { + this.server = new HttpServer(HTTP_PORT, version); + this.server.GetReceived += this.GetReceived; + + this.serverThread = new Thread(this.server.Start) { Name = "OtpListenerServerThread", IsBackground = true }; + } + + private void GetReceived(object sender, HttpServer.HttpServerGetEvent e) + { + if (e.Path.StartsWith("/ffxivlauncher/", StringComparison.Ordinal)) + { + var otp = e.Path.Substring(15); + + OnOtpReceived?.Invoke(otp); + } + } + + public void Start() + { + this.serverThread.Start(); + } + + public void Stop() + { + this.server?.Stop(); + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IIndexedZiPatchIndexInstaller.cs b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IIndexedZiPatchIndexInstaller.cs new file mode 100644 index 0000000..cce3086 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IIndexedZiPatchIndexInstaller.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace XIVLauncher2.Common.Patching.IndexedZiPatch +{ + public interface IIndexedZiPatchIndexInstaller : IDisposable + { + public event IndexedZiPatchInstaller.OnInstallProgressDelegate OnInstallProgress; + public event IndexedZiPatchInstaller.OnVerifyProgressDelegate OnVerifyProgress; + + public Task ConstructFromPatchFile(IndexedZiPatchIndex patchIndex, int progressReportInterval = 250); + + public Task VerifyFiles(bool refine = false, int concurrentCount = 8, CancellationToken? cancellationToken = null); + + public Task MarkFileAsMissing(int targetIndex, CancellationToken? cancellationToken = null); + + public Task SetTargetStreamFromPathReadOnly(int targetIndex, string path, CancellationToken? cancellationToken = null); + + public Task SetTargetStreamFromPathReadWrite(int targetIndex, string path, CancellationToken? cancellationToken = null); + + public Task SetTargetStreamsFromPathReadOnly(string rootPath, CancellationToken? cancellationToken = null); + + public Task SetTargetStreamsFromPathReadWriteForMissingFiles(string rootPath, CancellationToken? cancellationToken = null); + + public Task RepairNonPatchData(CancellationToken? cancellationToken = null); + + public Task WriteVersionFiles(string rootPath, CancellationToken? cancellationToken = null); + + public Task QueueInstall(int sourceIndex, Uri sourceUrl, string sid, int splitBy = 8, CancellationToken? cancellationToken = null); + + public Task QueueInstall(int sourceIndex, FileInfo sourceFile, int splitBy = 8, CancellationToken? cancellationToken = null); + + public Task Install(int concurrentCount, CancellationToken? cancellationToken = null); + + public Task>>> GetMissingPartIndicesPerPatch(CancellationToken? cancellationToken = null); + + public Task>> GetMissingPartIndicesPerTargetFile(CancellationToken? cancellationToken = null); + + public Task> GetSizeMismatchTargetFileIndices(CancellationToken? cancellationToken = null); + + public Task SetWorkerProcessPriority(ProcessPriorityClass subprocessPriority, CancellationToken? cancellationToken = null); + + public Task MoveFile(string sourceFile, string targetFile, CancellationToken? cancellationToken = null); + + public Task CreateDirectory(string dir, CancellationToken? cancellationToken = null); + + public Task RemoveDirectory(string dir, bool recursive = false, CancellationToken? cancellationToken = null); + } +} diff --git a/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchIndex.cs b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchIndex.cs new file mode 100644 index 0000000..a7fdefa --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchIndex.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using XIVLauncher2.Common.Patching.ZiPatch; +using XIVLauncher2.Common.Patching.ZiPatch.Chunk; +using XIVLauncher2.Common.Patching.ZiPatch.Chunk.SqpkCommand; +using XIVLauncher2.Common.Patching.ZiPatch.Util; + +namespace XIVLauncher2.Common.Patching.IndexedZiPatch +{ + public class IndexedZiPatchIndex + { + public const int EXPAC_VERSION_BOOT = -1; + public const int EXPAC_VERSION_BASE_GAME = 0; + + public readonly int ExpacVersion; + + private readonly List sourceFiles = new(); + private readonly List sourceFileLastPtr = new(); + private readonly List targetFiles = new(); + private readonly List>> sourceFilePartsCache = new(); + + public IndexedZiPatchIndex(int expacVersion) + { + ExpacVersion = expacVersion; + } + + public IndexedZiPatchIndex(BinaryReader reader, bool disposeReader = true) + { + try + { + ExpacVersion = reader.ReadInt32(); + + for (int i = 0, readIndex = reader.ReadInt32(); i < readIndex; i++) + this.sourceFiles.Add(reader.ReadString()); + foreach (var _ in this.sourceFiles) + this.sourceFileLastPtr.Add(reader.ReadInt32()); + + for (int i = 0, readIndex = reader.ReadInt32(); i < readIndex; i++) + this.targetFiles.Add(new IndexedZiPatchTargetFile(reader, false)); + } + finally + { + if (disposeReader) + { + reader.Dispose(); + } + } + } + + public IList Sources => this.sourceFiles.AsReadOnly(); + public int GetSourceLastPtr(int index) => this.sourceFileLastPtr[index]; + public IList Targets => this.targetFiles.AsReadOnly(); + + public IList>> SourceParts + { + get + { + for (var sourceFileIndex = this.sourceFilePartsCache.Count; sourceFileIndex < this.sourceFiles.Count; sourceFileIndex++) + { + var list = new List>(); + for (var i = 0; i < this.targetFiles.Count; i++) + for (var j = 0; j < this.targetFiles[i].Count; j++) + if (this.targetFiles[i][j].SourceIndex == sourceFileIndex) + list.Add(Tuple.Create(i, j)); + list.Sort((x, y) => this.targetFiles[x.Item1][x.Item2].SourceOffset.CompareTo(this.targetFiles[y.Item1][y.Item2].SourceOffset)); + this.sourceFilePartsCache.Add(list.AsReadOnly()); + } + + return this.sourceFilePartsCache.AsReadOnly(); + } + } + + public IndexedZiPatchTargetFile this[int index] => this.targetFiles[index]; + public IndexedZiPatchTargetFile this[string name] => this.targetFiles[IndexOf(name)]; + public int IndexOf(string name) => this.targetFiles.FindIndex(x => x.RelativePath == NormalizePath(name)); + public int Length => this.targetFiles.Count; + public string VersionName => this.sourceFiles.Last().Substring(1, this.sourceFiles.Last().Length - 7); + public string VersionFileBase => ExpacVersion == EXPAC_VERSION_BOOT ? "ffxivboot" : ExpacVersion == EXPAC_VERSION_BASE_GAME ? "ffxivgame" : $"sqpack/ex{ExpacVersion}/ex{ExpacVersion}"; + public string VersionFileVer => VersionFileBase + ".ver"; + public string VersionFileBck => VersionFileBase + ".bck"; + + private void ReassignTargetIndices() + { + for (int i = 0; i < this.targetFiles.Count; i++) + { + for (var j = 0; j < this.targetFiles[i].Count; j++) + { + var obj = this.targetFiles[i][j]; + obj.TargetIndex = i; + this.targetFiles[i][j] = obj; + } + } + } + + private Tuple AllocFile(string target) + { + target = NormalizePath(target); + var targetFileIndex = IndexOf(target); + if (targetFileIndex == -1) + { + this.targetFiles.Add(new(target)); + targetFileIndex = this.targetFiles.Count - 1; + } + return Tuple.Create(targetFileIndex, this.targetFiles[targetFileIndex]); + } + + public async Task ApplyZiPatch(string patchFileName, ZiPatchFile patchFile, CancellationToken? cancellationToken = null) + { + await Task.Run(() => + { + var sourceIndex = this.sourceFiles.Count; + this.sourceFiles.Add(patchFileName); + this.sourceFileLastPtr.Add(0); + this.sourceFilePartsCache.Clear(); + + var platform = ZiPatchConfig.PlatformId.Win32; + foreach (var patchChunk in patchFile.GetChunks()) + { + if (cancellationToken.HasValue) + cancellationToken.Value.ThrowIfCancellationRequested(); + + if (patchChunk is DeleteDirectoryChunk deleteDirectoryChunk) + { + var prefix = NormalizePath(deleteDirectoryChunk.DirName.ToLowerInvariant()); + this.targetFiles.RemoveAll(x => x.RelativePath.ToLowerInvariant().StartsWith(prefix)); + ReassignTargetIndices(); + } + else if (patchChunk is SqpkTargetInfo sqpkTargetInfo) + { + platform = sqpkTargetInfo.Platform; + } + else if (patchChunk is SqpkFile sqpkFile) + { + switch (sqpkFile.Operation) + { + case SqpkFile.OperationKind.AddFile: + var (targetIndex, file) = AllocFile(sqpkFile.TargetFile.RelativePath); + if (sqpkFile.FileOffset == 0) + file.Clear(); + + var offset = sqpkFile.FileOffset; + for (var i = 0; i < sqpkFile.CompressedData.Count; ++i) + { + if (cancellationToken.HasValue) + cancellationToken.Value.ThrowIfCancellationRequested(); + + var block = sqpkFile.CompressedData[i]; + var dataOffset = (int)sqpkFile.CompressedDataSourceOffsets[i]; + if (block.IsCompressed) + { + file.Update(new IndexedZiPatchPartLocator + { + TargetOffset = offset, + TargetSize = block.DecompressedSize, + TargetIndex = targetIndex, + SourceIndex = sourceIndex, + SourceOffset = dataOffset, + IsDeflatedBlockData = true, + }); + this.sourceFileLastPtr[this.sourceFileLastPtr.Count - 1] = dataOffset + block.CompressedSize; + } + else + { + file.Update(new IndexedZiPatchPartLocator + { + TargetOffset = offset, + TargetSize = block.DecompressedSize, + TargetIndex = targetIndex, + SourceIndex = sourceIndex, + SourceOffset = dataOffset, + }); + this.sourceFileLastPtr[this.sourceFileLastPtr.Count - 1] = dataOffset + block.DecompressedSize; + } + offset += block.DecompressedSize; + } + + break; + + case SqpkFile.OperationKind.RemoveAll: + var xpacPath = SqexFile.GetExpansionFolder((byte)sqpkFile.ExpansionId); + + this.targetFiles.RemoveAll(x => x.RelativePath.ToLowerInvariant().StartsWith($"sqpack/{xpacPath}")); + this.targetFiles.RemoveAll(x => x.RelativePath.ToLowerInvariant().StartsWith($"movie/{xpacPath}")); + ReassignTargetIndices(); + break; + + case SqpkFile.OperationKind.DeleteFile: + this.targetFiles.RemoveAll(x => x.RelativePath.ToLowerInvariant() == sqpkFile.TargetFile.RelativePath.ToLowerInvariant()); + ReassignTargetIndices(); + break; + } + } + else if (patchChunk is SqpkAddData sqpkAddData) + { + sqpkAddData.TargetFile.ResolvePath(platform); + var (targetIndex, file) = AllocFile(sqpkAddData.TargetFile.RelativePath); + file.Update(new IndexedZiPatchPartLocator + { + TargetOffset = sqpkAddData.BlockOffset, + TargetSize = sqpkAddData.BlockNumber, + TargetIndex = targetIndex, + SourceIndex = sourceIndex, + SourceOffset = sqpkAddData.BlockDataSourceOffset, + Crc32OrPlaceholderEntryDataUnits = (uint)(sqpkAddData.BlockNumber >> 7) - 1, + }); + this.sourceFileLastPtr[this.sourceFileLastPtr.Count - 1] = (int)(sqpkAddData.BlockDataSourceOffset + sqpkAddData.BlockNumber); + file.Update(new IndexedZiPatchPartLocator + { + TargetOffset = sqpkAddData.BlockOffset + sqpkAddData.BlockNumber, + TargetSize = sqpkAddData.BlockDeleteNumber, + TargetIndex = targetIndex, + SourceIndex = IndexedZiPatchPartLocator.SOURCE_INDEX_ZEROS, + Crc32OrPlaceholderEntryDataUnits = (uint)(sqpkAddData.BlockDeleteNumber >> 7) - 1, + }); + } + else if (patchChunk is SqpkDeleteData sqpkDeleteData) + { + sqpkDeleteData.TargetFile.ResolvePath(platform); + var (targetIndex, file) = AllocFile(sqpkDeleteData.TargetFile.RelativePath); + if (sqpkDeleteData.BlockNumber > 0) + { + file.Update(new IndexedZiPatchPartLocator + { + TargetOffset = sqpkDeleteData.BlockOffset, + TargetSize = 1 << 7, + TargetIndex = targetIndex, + SourceIndex = IndexedZiPatchPartLocator.SOURCE_INDEX_EMPTY_BLOCK, + Crc32OrPlaceholderEntryDataUnits = (uint)sqpkDeleteData.BlockNumber - 1, + }); + if (sqpkDeleteData.BlockNumber > 1) + { + file.Update(new IndexedZiPatchPartLocator + { + TargetOffset = sqpkDeleteData.BlockOffset + (1 << 7), + TargetSize = (sqpkDeleteData.BlockNumber - 1) << 7, + TargetIndex = targetIndex, + SourceIndex = IndexedZiPatchPartLocator.SOURCE_INDEX_ZEROS, + }); + } + } + } + else if (patchChunk is SqpkExpandData sqpkExpandData) + { + sqpkExpandData.TargetFile.ResolvePath(platform); + var (targetIndex, file) = AllocFile(sqpkExpandData.TargetFile.RelativePath); + if (sqpkExpandData.BlockNumber > 0) + { + file.Update(new IndexedZiPatchPartLocator + { + TargetOffset = sqpkExpandData.BlockOffset, + TargetSize = 1 << 7, + TargetIndex = targetIndex, + SourceIndex = IndexedZiPatchPartLocator.SOURCE_INDEX_EMPTY_BLOCK, + Crc32OrPlaceholderEntryDataUnits = (uint)sqpkExpandData.BlockNumber - 1, + }); + if (sqpkExpandData.BlockNumber > 1) + { + file.Update(new IndexedZiPatchPartLocator + { + TargetOffset = sqpkExpandData.BlockOffset + (1 << 7), + TargetSize = (sqpkExpandData.BlockNumber - 1) << 7, + TargetIndex = targetIndex, + SourceIndex = IndexedZiPatchPartLocator.SOURCE_INDEX_ZEROS, + }); + } + } + } + else if (patchChunk is SqpkHeader sqpkHeader) + { + sqpkHeader.TargetFile.ResolvePath(platform); + var (targetIndex, file) = AllocFile(sqpkHeader.TargetFile.RelativePath); + file.Update(new IndexedZiPatchPartLocator + { + TargetOffset = sqpkHeader.HeaderKind == SqpkHeader.TargetHeaderKind.Version ? 0 : SqpkHeader.HEADER_SIZE, + TargetSize = SqpkHeader.HEADER_SIZE, + TargetIndex = targetIndex, + SourceIndex = sourceIndex, + SourceOffset = sqpkHeader.HeaderDataSourceOffset, + }); + this.sourceFileLastPtr[this.sourceFileLastPtr.Count - 1] = (int)(sqpkHeader.HeaderDataSourceOffset + SqpkHeader.HEADER_SIZE); + } + } + }); + } + + public async Task CalculateCrc32(List sources, CancellationToken? cancellationToken = null) + { + foreach (var file in this.targetFiles) + { + if (cancellationToken.HasValue) + cancellationToken.Value.ThrowIfCancellationRequested(); + await file.CalculateCrc32(sources, cancellationToken); + } + } + + public void WriteTo(BinaryWriter writer) + { + writer.Write(ExpacVersion); + + writer.Write(this.sourceFiles.Count); + foreach (var file in this.sourceFiles) + writer.Write(file); + foreach (var file in this.sourceFileLastPtr) + writer.Write(file); + + writer.Write(this.targetFiles.Count); + foreach (var file in this.targetFiles) + file.WriteTo(writer); + } + + private static string NormalizePath(string path) + { + if (path == "") + return path; + path = path.Replace("\\", "/"); + while (path[0] == '/') + path = path.Substring(1); + return path; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchIndexLocalInstaller.cs b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchIndexLocalInstaller.cs new file mode 100644 index 0000000..b93ead4 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchIndexLocalInstaller.cs @@ -0,0 +1,156 @@ +using Serilog; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace XIVLauncher2.Common.Patching.IndexedZiPatch +{ + public class IndexedZiPatchIndexLocalInstaller : IIndexedZiPatchIndexInstaller + { + private int cancellationTokenCounter = 1; + private long lastProgressUpdateCounter = 0; + private bool isDisposed = false; + private IndexedZiPatchInstaller? instance; + + public event IndexedZiPatchInstaller.OnInstallProgressDelegate OnInstallProgress; + public event IndexedZiPatchInstaller.OnVerifyProgressDelegate OnVerifyProgress; + + public IndexedZiPatchIndexLocalInstaller() + { + this.instance = null; + } + + public void Dispose() + { + if (this.isDisposed) + throw new ObjectDisposedException(GetType().FullName); + + this.isDisposed = true; + } + + public Task ConstructFromPatchFile(IndexedZiPatchIndex patchIndex, int progressReportInterval = 250) + { + this.instance?.Dispose(); + this.instance = new(patchIndex) + { + ProgressReportInterval = progressReportInterval, + }; + this.instance.OnInstallProgress += OnInstallProgress; + this.instance.OnVerifyProgress += OnVerifyProgress; + return Task.CompletedTask; + } + + public async Task VerifyFiles(bool refine = false, int concurrentCount = 8, CancellationToken? cancellationToken = null) + { + await this.instance.VerifyFiles(refine, concurrentCount, cancellationToken); + } + + public Task MarkFileAsMissing(int targetIndex, CancellationToken? cancellationToken = null) + { + this.instance.MarkFileAsMissing(targetIndex); + return Task.CompletedTask; + } + + public Task SetTargetStreamFromPathReadOnly(int targetIndex, string path, CancellationToken? cancellationToken = null) + { + this.instance.SetTargetStreamForRead(targetIndex, new FileStream(path, FileMode.Open, FileAccess.Read)); + return Task.CompletedTask; + } + + public Task SetTargetStreamFromPathReadWrite(int targetIndex, string path, CancellationToken? cancellationToken = null) + { + this.instance.SetTargetStreamForWriteFromFile(targetIndex, new FileInfo(path)); + return Task.CompletedTask; + } + + public Task SetTargetStreamsFromPathReadOnly(string rootPath, CancellationToken? cancellationToken = null) + { + this.instance.SetTargetStreamsFromPathReadOnly(rootPath); + return Task.CompletedTask; + } + + public Task SetTargetStreamsFromPathReadWriteForMissingFiles(string rootPath, CancellationToken? cancellationToken = null) + { + this.instance.SetTargetStreamsFromPathReadWriteForMissingFiles(rootPath); + return Task.CompletedTask; + } + + public async Task RepairNonPatchData(CancellationToken? cancellationToken = null) + { + await this.instance.RepairNonPatchData(cancellationToken); + } + + public Task WriteVersionFiles(string rootPath, CancellationToken? cancellationToken = null) + { + this.instance.WriteVersionFiles(rootPath); + return Task.CompletedTask; + } + + public Task QueueInstall(int sourceIndex, Uri sourceUrl, string sid, int splitBy = 8, CancellationToken? cancellationToken = null) + { + this.instance.QueueInstall(sourceIndex, sourceUrl.OriginalString, sid, splitBy); + return Task.CompletedTask; + } + + public Task QueueInstall(int sourceIndex, FileInfo sourceFile, int splitBy = 8, CancellationToken? cancellationToken = null) + { + this.instance.QueueInstall(sourceIndex, sourceFile, splitBy); + return Task.CompletedTask; + } + + public async Task Install(int concurrentCount, CancellationToken? cancellationToken = null) + { + await this.instance.Install(concurrentCount, cancellationToken); + } + + public Task>>> GetMissingPartIndicesPerPatch(CancellationToken? cancellationToken = null) + { + return Task.FromResult(this.instance.MissingPartIndicesPerPatch); + } + + public Task>> GetMissingPartIndicesPerTargetFile(CancellationToken? cancellationToken = null) + { + return Task.FromResult(this.instance.MissingPartIndicesPerTargetFile); + } + + public Task> GetSizeMismatchTargetFileIndices(CancellationToken? cancellationToken = null) + { + return Task.FromResult(this.instance.SizeMismatchTargetFileIndices); + } + + public Task SetWorkerProcessPriority(ProcessPriorityClass subprocessPriority, CancellationToken? cancellationToken = null) + { + return Task.CompletedTask; // is a no-op locally + } + + public Task MoveFile(string sourceFile, string targetFile, CancellationToken? cancellationToken = null) + { + var sourceParentDir = new DirectoryInfo(Path.GetDirectoryName(sourceFile)); + var targetParentDir = new DirectoryInfo(Path.GetDirectoryName(targetFile)); + + targetParentDir.Create(); + new FileInfo(sourceFile).MoveTo(targetFile); + + if (!sourceParentDir.GetFileSystemInfos().Any()) + sourceParentDir.Delete(false); + + return Task.CompletedTask; + } + + public Task CreateDirectory(string dir, CancellationToken? cancellationToken = null) + { + new DirectoryInfo(dir).Create(); + return Task.CompletedTask; + } + + public Task RemoveDirectory(string dir, bool recursive = false, CancellationToken? cancellationToken = null) + { + new DirectoryInfo(dir).Delete(recursive); + return Task.CompletedTask; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchIndexRemoteInstaller.cs b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchIndexRemoteInstaller.cs new file mode 100644 index 0000000..e7c91c0 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchIndexRemoteInstaller.cs @@ -0,0 +1,701 @@ +using Serilog; +using SharedMemory; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace XIVLauncher2.Common.Patching.IndexedZiPatch +{ + public class IndexedZiPatchIndexRemoteInstaller : IIndexedZiPatchIndexInstaller + { + private readonly Process workerProcess; + private readonly RpcBuffer subprocessBuffer; + private int cancellationTokenCounter = 1; + private long lastProgressUpdateCounter = 0; + private bool isDisposed = false; + + public event IndexedZiPatchInstaller.OnInstallProgressDelegate OnInstallProgress; + public event IndexedZiPatchInstaller.OnVerifyProgressDelegate OnVerifyProgress; + + public IndexedZiPatchIndexRemoteInstaller(string workerExecutablePath, bool asAdmin) + { + var rpcChannelName = "RemoteZiPatchIndexInstaller" + Guid.NewGuid().ToString(); + this.subprocessBuffer = new RpcBuffer(rpcChannelName, RpcResponseHandler); + + if (workerExecutablePath != null) + { + this.workerProcess = new(); + this.workerProcess.StartInfo.FileName = workerExecutablePath; + this.workerProcess.StartInfo.UseShellExecute = true; + this.workerProcess.StartInfo.Verb = asAdmin ? "runas" : "open"; + this.workerProcess.StartInfo.Arguments = $"index-rpc {Process.GetCurrentProcess().Id} {rpcChannelName}"; + this.workerProcess.Start(); + } + else + { + this.workerProcess = null; + Task.Run(() => new WorkerSubprocessBody(Process.GetCurrentProcess().Id, rpcChannelName).RunToDisposeSelf()); + } + } + + public void Dispose() + { + if (this.isDisposed) + throw new ObjectDisposedException(GetType().FullName); + + try + { + this.subprocessBuffer.RemoteRequest(((MemoryStream)GetRequestCreator(WorkerInboundOpcode.DisposeAndExit, null).BaseStream).ToArray(), 100); + } + catch (Exception) + { + // ignore any exception + } + + if (this.workerProcess != null && !this.workerProcess.HasExited) + { + this.workerProcess.WaitForExit(1000); + try + { + this.workerProcess.Kill(); + } + catch (Exception) + { + if (!this.workerProcess.HasExited) + throw; + } + } + this.subprocessBuffer.Dispose(); + this.isDisposed = true; + } + + private void RpcResponseHandler(ulong _, byte[] data) + { + using var reader = new BinaryReader(new MemoryStream(data)); + var type = (WorkerOutboundOpcode)reader.ReadInt32(); + switch (type) + { + case WorkerOutboundOpcode.UpdateInstallProgress: + OnReceiveInstallProgressUpdate(reader); + break; + + case WorkerOutboundOpcode.UpdateVerifyProgress: + OnReceiveVerifyProgressUpdate(reader); + break; + + default: + throw new ArgumentException("Unknown recv opc"); + } + } + + private void OnReceiveInstallProgressUpdate(BinaryReader reader) + { + var progressUpdateCounter = reader.ReadInt64(); + if (progressUpdateCounter < this.lastProgressUpdateCounter) + return; + + this.lastProgressUpdateCounter = progressUpdateCounter; + var index = reader.ReadInt32(); + var progress = reader.ReadInt64(); + var max = reader.ReadInt64(); + var state = (IndexedZiPatchInstaller.InstallTaskState)reader.ReadInt32(); + + OnInstallProgress?.Invoke(index, progress, max, state); + } + + private void OnReceiveVerifyProgressUpdate(BinaryReader reader) + { + var progressUpdateCounter = reader.ReadInt64(); + if (progressUpdateCounter < this.lastProgressUpdateCounter) + return; + + this.lastProgressUpdateCounter = progressUpdateCounter; + var index = reader.ReadInt32(); + var progress = reader.ReadInt64(); + var max = reader.ReadInt64(); + + OnVerifyProgress?.Invoke(index, progress, max); + } + + private BinaryWriter GetRequestCreator(WorkerInboundOpcode opcode, CancellationToken? cancellationToken) + { + var ms = new MemoryStream(); + var writer = new BinaryWriter(ms); + var tokenId = -1; + if (cancellationToken.HasValue) + { + tokenId = this.cancellationTokenCounter++; + cancellationToken.Value.Register(async () => await CancelRemoteTask(tokenId)); + } + writer.Write(tokenId); + writer.Write((int)opcode); + return writer; + } + + private async Task WaitForResult(BinaryWriter req, CancellationToken? cancellationToken, int timeoutMs = 30000, bool autoDispose = true) + { + var requestData = ((MemoryStream)req.BaseStream).ToArray(); + RpcResponse response; + if (cancellationToken.HasValue) + response = await this.subprocessBuffer.RemoteRequestAsync(requestData, timeoutMs, cancellationToken.Value); + else + response = await this.subprocessBuffer.RemoteRequestAsync(requestData, timeoutMs); + if (cancellationToken.HasValue) + cancellationToken.Value.ThrowIfCancellationRequested(); + + if (this.isDisposed) + throw new OperationCanceledException(); + var reader = new BinaryReader(new MemoryStream(response.Data)); + try + { + var result = (WorkerResultCode)reader.ReadInt32(); + return result switch + { + WorkerResultCode.Pass => reader, + WorkerResultCode.Cancelled => throw new TaskCanceledException(), + WorkerResultCode.Error => throw new Exception(reader.ReadString()), + _ => throw new InvalidOperationException("Invalid WorkerResultCodes"), + }; + } + finally + { + if (autoDispose) + reader.Dispose(); + } + } + + private async Task CancelRemoteTask(int tokenId) + { + if (this.isDisposed) + return; + + try + { + var writer = GetRequestCreator(WorkerInboundOpcode.CancelTask, null); + writer.Write(tokenId); + await WaitForResult(writer, null); + } + catch (OperationCanceledException) + { + // ignore + } + } + + public async Task ConstructFromPatchFile(IndexedZiPatchIndex patchIndex, int progressReportInterval = 250) + { + var writer = GetRequestCreator(WorkerInboundOpcode.Construct, null); + patchIndex.WriteTo(writer); + writer.Write(progressReportInterval); + await WaitForResult(writer, null); + } + + public async Task VerifyFiles(bool refine = false, int concurrentCount = 8, CancellationToken? cancellationToken = null) + { + var writer = GetRequestCreator(WorkerInboundOpcode.VerifyFiles, cancellationToken); + writer.Write(refine); + writer.Write(concurrentCount); + await WaitForResult(writer, cancellationToken, 864000000); + } + + public async Task MarkFileAsMissing(int targetIndex, CancellationToken? cancellationToken = null) + { + var writer = GetRequestCreator(WorkerInboundOpcode.MarkFileAsMissing, cancellationToken); + writer.Write(targetIndex); + await WaitForResult(writer, cancellationToken); + } + + public async Task SetTargetStreamFromPathReadOnly(int targetIndex, string path, CancellationToken? cancellationToken = null) + { + var writer = GetRequestCreator(WorkerInboundOpcode.SetTargetStreamFromPathReadOnly, cancellationToken); + writer.Write(targetIndex); + writer.Write(path); + await WaitForResult(writer, cancellationToken); + } + + public async Task SetTargetStreamFromPathReadWrite(int targetIndex, string path, CancellationToken? cancellationToken = null) + { + var writer = GetRequestCreator(WorkerInboundOpcode.SetTargetStreamFromPathReadWrite, cancellationToken); + writer.Write(targetIndex); + writer.Write(path); + await WaitForResult(writer, cancellationToken); + } + + public async Task SetTargetStreamsFromPathReadOnly(string rootPath, CancellationToken? cancellationToken = null) + { + var writer = GetRequestCreator(WorkerInboundOpcode.SetTargetStreamsFromPathReadOnly, cancellationToken); + writer.Write(rootPath); + await WaitForResult(writer, cancellationToken); + } + + public async Task SetTargetStreamsFromPathReadWriteForMissingFiles(string rootPath, CancellationToken? cancellationToken = null) + { + var writer = GetRequestCreator(WorkerInboundOpcode.SetTargetStreamsFromPathReadWriteForMissingFiles, cancellationToken); + writer.Write(rootPath); + await WaitForResult(writer, cancellationToken); + } + + public async Task RepairNonPatchData(CancellationToken? cancellationToken = null) => await WaitForResult(GetRequestCreator(WorkerInboundOpcode.RepairNonPatchData, cancellationToken), cancellationToken); + + public async Task WriteVersionFiles(string rootPath, CancellationToken? cancellationToken = null) + { + var writer = GetRequestCreator(WorkerInboundOpcode.WriteVersionFiles, cancellationToken); + writer.Write(rootPath); + await WaitForResult(writer, cancellationToken); + } + + public async Task QueueInstall(int sourceIndex, Uri sourceUrl, string sid, int splitBy = 8, CancellationToken? cancellationToken = null) + { + var writer = GetRequestCreator(WorkerInboundOpcode.QueueInstallFromUrl, cancellationToken); + writer.Write(sourceIndex); + writer.Write(sourceUrl.OriginalString); + writer.Write(sid ?? ""); + writer.Write(splitBy); + await WaitForResult(writer, cancellationToken); + } + + public async Task QueueInstall(int sourceIndex, FileInfo sourceFile, int splitBy = 8, CancellationToken? cancellationToken = null) + { + var writer = GetRequestCreator(WorkerInboundOpcode.QueueInstallFromLocalFile, cancellationToken); + writer.Write(sourceIndex); + writer.Write(sourceFile.FullName); + writer.Write(splitBy); + await WaitForResult(writer, cancellationToken); + } + + public async Task Install(int concurrentCount, CancellationToken? cancellationToken = null) + { + var writer = GetRequestCreator(WorkerInboundOpcode.Install, cancellationToken); + writer.Write(concurrentCount); + await WaitForResult(writer, cancellationToken, 864000000); + } + + public async Task>>> GetMissingPartIndicesPerPatch(CancellationToken? cancellationToken = null) + { + using var reader = await WaitForResult(GetRequestCreator(WorkerInboundOpcode.GetMissingPartIndicesPerPatch, cancellationToken), cancellationToken, 30000, false); + List>> result = new(); + for (int i = 0, iReadLength = reader.ReadInt32(); i < iReadLength; i++) + { + SortedSet> e1 = new(); + for (int j = 0, jReadLength = reader.ReadInt32(); j < jReadLength; j++) + e1.Add(Tuple.Create(reader.ReadInt32(), reader.ReadInt32())); + result.Add(e1); + } + return result; + } + + public async Task>> GetMissingPartIndicesPerTargetFile(CancellationToken? cancellationToken = null) + { + using var reader = await WaitForResult(GetRequestCreator(WorkerInboundOpcode.GetMissingPartIndicesPerTargetFile, cancellationToken), cancellationToken, 30000, false); + List> result = new(); + for (int i = 0, iReadLength = reader.ReadInt32(); i < iReadLength; i++) + { + SortedSet e1 = new(); + for (int j = 0, jReadLength = reader.ReadInt32(); j < jReadLength; j++) + e1.Add(reader.ReadInt32()); + result.Add(e1); + } + return result; + } + + public async Task> GetSizeMismatchTargetFileIndices(CancellationToken? cancellationToken = null) + { + using var reader = await WaitForResult(GetRequestCreator(WorkerInboundOpcode.GetSizeMismatchTargetFileIndices, cancellationToken), cancellationToken, 30000, false); + SortedSet result = new(); + for (int i = 0, readIndex = reader.ReadInt32(); i < readIndex; i++) + result.Add(reader.ReadInt32()); + return result; + } + + public async Task SetWorkerProcessPriority(ProcessPriorityClass subprocessPriority, CancellationToken? cancellationToken = null) + { + var writer = GetRequestCreator(WorkerInboundOpcode.SetWorkerProcessPriority, cancellationToken); + writer.Write((int)subprocessPriority); + await WaitForResult(writer, cancellationToken); + } + + public async Task MoveFile(string sourceFile, string targetFile, CancellationToken? cancellationToken = null) { + var writer = GetRequestCreator(WorkerInboundOpcode.MoveFile, cancellationToken); + writer.Write(sourceFile); + writer.Write(targetFile); + await WaitForResult(writer, cancellationToken); + } + + public async Task CreateDirectory(string dir, CancellationToken? cancellationToken = null) { + var writer = GetRequestCreator(WorkerInboundOpcode.CreateDirectory, cancellationToken); + writer.Write(dir); + await WaitForResult(writer, cancellationToken); + } + + public async Task RemoveDirectory(string dir, bool recursive = false, CancellationToken? cancellationToken = null) { + var writer = GetRequestCreator(WorkerInboundOpcode.RemoveDirectory, cancellationToken); + writer.Write(dir); + writer.Write(recursive); + await WaitForResult(writer, cancellationToken); + } + + public class WorkerSubprocessBody : IDisposable + { + private readonly object progressUpdateSync = new(); + private readonly Process parentProcess; + private readonly RpcBuffer subprocessBuffer; + private readonly Dictionary cancellationTokenSources = new(); + private IndexedZiPatchInstaller instance = null; + private long progressUpdateCounter = 0; + + public WorkerSubprocessBody(int monitorProcessId, string channelName) + { + this.parentProcess = Process.GetProcessById(monitorProcessId); + this.subprocessBuffer = new RpcBuffer(channelName, async (ulong _, byte[] data) => + { + using var reader = new BinaryReader(new MemoryStream(data)); + var cancelSourceId = reader.ReadInt32(); + CancellationToken? cancelToken = null; + if (cancelSourceId != -1) + { + this.cancellationTokenSources[cancelSourceId] = new CancellationTokenSource(); + cancelToken = this.cancellationTokenSources[cancelSourceId].Token; + } + var method = (WorkerInboundOpcode)reader.ReadInt32(); + + var ms = new MemoryStream(); + var writer = new BinaryWriter(ms); + writer.Write(0); + + try + { + switch (method) + { + case WorkerInboundOpcode.CancelTask: + lock (this.cancellationTokenSources) + { + if (this.cancellationTokenSources.TryGetValue(reader.ReadInt32(), out var cts)) + cts.Cancel(); + } + break; + + case WorkerInboundOpcode.Construct: + this.instance?.Dispose(); + this.instance = new(new IndexedZiPatchIndex(reader, false)) + { + ProgressReportInterval = reader.ReadInt32(), + }; + this.instance.OnInstallProgress += OnInstallProgressUpdate; + this.instance.OnVerifyProgress += OnVerifyProgressUpdate; + break; + + case WorkerInboundOpcode.DisposeAndExit: + this.instance?.Dispose(); + this.instance = null; + Environment.Exit(0); + break; + + case WorkerInboundOpcode.VerifyFiles: + await this.instance.VerifyFiles(reader.ReadBoolean(), reader.ReadInt32(), cancelToken); + break; + + case WorkerInboundOpcode.MarkFileAsMissing: + this.instance.MarkFileAsMissing(reader.ReadInt32()); + break; + + case WorkerInboundOpcode.SetTargetStreamFromPathReadOnly: + this.instance.SetTargetStreamForRead(reader.ReadInt32(), new FileStream(reader.ReadString(), FileMode.Open, FileAccess.Read)); + break; + + case WorkerInboundOpcode.SetTargetStreamFromPathReadWrite: + this.instance.SetTargetStreamForWriteFromFile(reader.ReadInt32(), new FileInfo(reader.ReadString())); + break; + + case WorkerInboundOpcode.SetTargetStreamsFromPathReadOnly: + this.instance.SetTargetStreamsFromPathReadOnly(reader.ReadString()); + break; + + case WorkerInboundOpcode.SetTargetStreamsFromPathReadWriteForMissingFiles: + this.instance.SetTargetStreamsFromPathReadWriteForMissingFiles(reader.ReadString()); + break; + + case WorkerInboundOpcode.RepairNonPatchData: + await this.instance.RepairNonPatchData(cancelToken); + break; + + case WorkerInboundOpcode.WriteVersionFiles: + this.instance.WriteVersionFiles(reader.ReadString()); + break; + + case WorkerInboundOpcode.QueueInstallFromUrl: + this.instance.QueueInstall(reader.ReadInt32(), reader.ReadString(), reader.ReadString(), reader.ReadInt32()); + break; + + case WorkerInboundOpcode.QueueInstallFromLocalFile: + this.instance.QueueInstall(reader.ReadInt32(), new FileInfo(reader.ReadString()), reader.ReadInt32()); + break; + + case WorkerInboundOpcode.Install: + await this.instance.Install(reader.ReadInt32(), cancelToken); + break; + + case WorkerInboundOpcode.GetMissingPartIndicesPerPatch: + writer.Write(this.instance.MissingPartIndicesPerPatch.Count); + foreach (var e1 in this.instance.MissingPartIndicesPerPatch) + { + writer.Write(e1.Count); + foreach (var e2 in e1) + { + writer.Write(e2.Item1); + writer.Write(e2.Item2); + } + } + break; + + case WorkerInboundOpcode.GetMissingPartIndicesPerTargetFile: + writer.Write(this.instance.MissingPartIndicesPerTargetFile.Count); + foreach (var e1 in this.instance.MissingPartIndicesPerTargetFile) + { + writer.Write(e1.Count); + foreach (var e2 in e1) + writer.Write(e2); + } + break; + + case WorkerInboundOpcode.GetSizeMismatchTargetFileIndices: + writer.Write(this.instance.SizeMismatchTargetFileIndices.Count); + foreach (var e1 in this.instance.SizeMismatchTargetFileIndices) + writer.Write(e1); + break; + + case WorkerInboundOpcode.SetWorkerProcessPriority: + Process.GetCurrentProcess().PriorityClass = (ProcessPriorityClass)reader.ReadInt32(); + break; + + case WorkerInboundOpcode.MoveFile: + { + var sourceFileName = reader.ReadString(); + var targetFileName = reader.ReadString(); + + var sourceParentDir = new DirectoryInfo(Path.GetDirectoryName(sourceFileName)); + var targetParentDir = new DirectoryInfo(Path.GetDirectoryName(targetFileName)); + + targetParentDir.Create(); + new FileInfo(sourceFileName).MoveTo(targetFileName); + + if (!sourceParentDir.GetFileSystemInfos().Any()) + sourceParentDir.Delete(false); + break; + } + + case WorkerInboundOpcode.CreateDirectory: + new DirectoryInfo(reader.ReadString()).Create(); + break; + + case WorkerInboundOpcode.RemoveDirectory: + { + var dir = new DirectoryInfo(reader.ReadString()); + var recursive = reader.ReadBoolean(); + dir.Delete(recursive); + break; + } + + default: + throw new InvalidOperationException("Invalid WorkerInboundOpcode"); + } + + writer.Seek(0, SeekOrigin.Begin); + writer.Write((int)WorkerResultCode.Pass); + } + catch (Exception ex) + { + writer.Seek(0, SeekOrigin.Begin); + if (ex is OperationCanceledException) + writer.Write((int)WorkerResultCode.Cancelled); + else + { + writer.Write((int)WorkerResultCode.Error); + writer.Write(ex.ToString()); + } + } + finally + { + if (cancelSourceId != -1) + this.cancellationTokenSources.Remove(cancelSourceId); + } + return ms.ToArray(); + }); + } + + private void OnInstallProgressUpdate(int index, long progress, long max, IndexedZiPatchInstaller.InstallTaskState state) + { + lock (this.progressUpdateSync) + { + var ms = new MemoryStream(); + var writer = new BinaryWriter(ms); + writer.Write((int)WorkerOutboundOpcode.UpdateInstallProgress); + writer.Write(this.progressUpdateCounter); + writer.Write(index); + writer.Write(progress); + writer.Write(max); + writer.Write((int)state); + this.progressUpdateCounter += 1; + this.subprocessBuffer.RemoteRequest(ms.ToArray()); + } + } + + private void OnVerifyProgressUpdate(int index, long progress, long max) + { + lock (this.progressUpdateSync) + { + var ms = new MemoryStream(); + var writer = new BinaryWriter(ms); + writer.Write((int)WorkerOutboundOpcode.UpdateVerifyProgress); + writer.Write(this.progressUpdateCounter); + writer.Write(index); + writer.Write(progress); + writer.Write(max); + this.progressUpdateCounter += 1; + this.subprocessBuffer.RemoteRequest(ms.ToArray()); + } + } + + public void Dispose() + { + this.subprocessBuffer.Dispose(); + this.instance?.Dispose(); + } + + public void Run() + { + this.parentProcess.WaitForExit(); + } + + public void RunToDisposeSelf() + { + try + { + Run(); + } + catch (OperationCanceledException) + { + // pass + } + finally + { + Dispose(); + } + } + } + + private enum WorkerResultCode : int + { + Pass, + Cancelled, + Error, + } + + private enum WorkerOutboundOpcode : int + { + UpdateInstallProgress, + UpdateVerifyProgress, + } + + private enum WorkerInboundOpcode : int + { + CancelTask, + Construct, + DisposeAndExit, + VerifyFiles, + MarkFileAsMissing, + SetTargetStreamFromPathReadOnly, + SetTargetStreamFromPathReadWrite, + SetTargetStreamsFromPathReadOnly, + SetTargetStreamsFromPathReadWriteForMissingFiles, + RepairNonPatchData, + WriteVersionFiles, + QueueInstallFromUrl, + QueueInstallFromLocalFile, + Install, + GetMissingPartIndicesPerPatch, + GetMissingPartIndicesPerTargetFile, + GetSizeMismatchTargetFileIndices, + SetWorkerProcessPriority, + MoveFile, + CreateDirectory, + RemoveDirectory, + } + + public static void Test() + { + Task.Run(async () => + { + // Cancel in 15 secs + var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + + var availableSourceUrls = new Dictionary() { + {"boot:D2013.06.18.0000.0000.patch", "http://patch-dl.ffxiv.com/boot/2b5cbc63/D2013.06.18.0000.0000.patch"}, + {"boot:D2021.11.16.0000.0001.patch", "http://patch-dl.ffxiv.com/boot/2b5cbc63/D2021.11.16.0000.0001.patch"}, + }; + var maxConcurrentConnectionsForPatchSet = 1; + + var baseDir = @"Z:\tgame"; + var rootAndPatchPairs = new List>() { + Tuple.Create(@$"{baseDir}\boot", @"Z:\patch-dl.ffxiv.com\boot\2b5cbc63\D2021.11.16.0000.0001.patch.index"), + }; + + // Run verifier as subprocess + // using var verifier = new IndexedZiPatchIndexRemoteInstaller(System.Reflection.Assembly.GetExecutingAssembly().Location, true); + // Run verifier as another thread + using var verifier = new IndexedZiPatchIndexRemoteInstaller(null, true); + + foreach (var (gameRootPath, patchIndexFilePath) in rootAndPatchPairs) + { + var patchIndex = new IndexedZiPatchIndex(new BinaryReader(new DeflateStream(new FileStream(patchIndexFilePath, FileMode.Open, FileAccess.Read), CompressionMode.Decompress))); + + await verifier.ConstructFromPatchFile(patchIndex, 1000); + + void ReportCheckProgress(int index, long progress, long max) + { + Log.Information("[{0}/{1}] Checking file {2}... {3:0.00}/{4:0.00}MB ({5:00.00}%)", index + 1, patchIndex.Length, patchIndex[Math.Min(index, patchIndex.Length - 1)].RelativePath, progress / 1048576.0, max / 1048576.0, 100.0 * progress / max); + } + + void ReportInstallProgress(int index, long progress, long max, IndexedZiPatchInstaller.InstallTaskState state) + { + Log.Information("[{0}/{1}] {2} {3}... {4:0.00}/{5:0.00}MB ({6:00.00}%)", index + 1, patchIndex.Sources.Count, state, patchIndex.Sources[Math.Min(index, patchIndex.Sources.Count - 1)], progress / 1048576.0, max / 1048576.0, 100.0 * progress / max); + } + + verifier.OnVerifyProgress += ReportCheckProgress; + verifier.OnInstallProgress += ReportInstallProgress; + + for (var attemptIndex = 0; attemptIndex < 5; attemptIndex++) + { + await verifier.SetTargetStreamsFromPathReadOnly(gameRootPath); + // TODO: check one at a time if random access is slow? + await verifier.VerifyFiles(attemptIndex > 0, Environment.ProcessorCount, cancellationToken); + + var missingPartIndicesPerTargetFile = await verifier.GetMissingPartIndicesPerTargetFile(); + if (missingPartIndicesPerTargetFile.All(x => !x.Any())) + break; + + var missingPartIndicesPerPatch = await verifier.GetMissingPartIndicesPerPatch(); + await verifier.SetTargetStreamsFromPathReadWriteForMissingFiles(gameRootPath); + var prefix = patchIndex.ExpacVersion == IndexedZiPatchIndex.EXPAC_VERSION_BOOT ? "boot:" : $"ex{patchIndex.ExpacVersion}:"; + for (var i = 0; i < patchIndex.Sources.Count; i++) + { + if (!missingPartIndicesPerPatch[i].Any()) + continue; + + await verifier.QueueInstall(i, new Uri(availableSourceUrls[prefix + patchIndex.Sources[i]]), null, maxConcurrentConnectionsForPatchSet); + // await verifier.QueueInstall(i, new FileInfo(availableSourceUrls[prefix + patchIndex.Sources[i]].Replace("http:/", "Z:"))); + } + await verifier.Install(maxConcurrentConnectionsForPatchSet, cancellationToken); + await verifier.WriteVersionFiles(gameRootPath); + } + verifier.OnVerifyProgress -= ReportCheckProgress; + verifier.OnInstallProgress -= ReportInstallProgress; + } + }).Wait(); + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchInstaller.cs b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchInstaller.cs new file mode 100644 index 0000000..2563d97 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchInstaller.cs @@ -0,0 +1,793 @@ +using Serilog; +using System; +using System.Collections.Generic; + +#if WIN32 +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +#endif + +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using XIVLauncher2.Common.Patching.Util; + +namespace XIVLauncher2.Common.Patching.IndexedZiPatch +{ + + public class IndexedZiPatchInstaller : IDisposable + { + public readonly IndexedZiPatchIndex Index; + public readonly List>> MissingPartIndicesPerPatch = new(); + public readonly List> MissingPartIndicesPerTargetFile = new(); + public readonly SortedSet SizeMismatchTargetFileIndices = new(); + + public int ProgressReportInterval = 250; + private readonly List targetStreams = new(); + private readonly List targetLocks = new(); + + public enum InstallTaskState + { + NotStarted, + WaitingForReattempt, + Connecting, + Working, + Finishing, + Done, + Error, + } + + public delegate void OnCorruptionFoundDelegate(IndexedZiPatchPartLocator part, IndexedZiPatchPartLocator.VerifyDataResult result); + public delegate void OnVerifyProgressDelegate(int targetIndex, long progress, long max); + public delegate void OnInstallProgressDelegate(int sourceIndex, long progress, long max, InstallTaskState state); + + public event OnCorruptionFoundDelegate OnCorruptionFound; + public event OnVerifyProgressDelegate OnVerifyProgress; + public event OnInstallProgressDelegate OnInstallProgress; + + // Definitions taken from PInvoke.net (with some changes) + // ReSharper disable InconsistentNaming + +#if WIN32 + private static class PInvoke + { + #region Constants + + public const UInt32 TOKEN_QUERY = 0x0008; + public const UInt32 TOKEN_ADJUST_PRIVILEGES = 0x0020; + + public const UInt32 SE_PRIVILEGE_ENABLED = 0x00000002; + + public const UInt32 ERROR_NOT_ALL_ASSIGNED = 0x514; + + #endregion + + #region Structures + + [StructLayout(LayoutKind.Sequential)] + public struct LUID + { + public UInt32 LowPart; + public Int32 HighPart; + } + + public struct LUID_AND_ATTRIBUTES + { + public LUID Luid; + public UInt32 Attributes; + } + + [StructLayout(LayoutKind.Sequential)] + public struct TOKEN_PRIVILEGES + { + public UInt32 PrivilegeCount; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 1)] + public LUID_AND_ATTRIBUTES[] Privileges; + } + + #endregion + + #region Methods + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetFileValidData(IntPtr hFile, long ValidDataLength); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle(IntPtr hObject); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool OpenProcessToken( + IntPtr ProcessHandle, + UInt32 DesiredAccess, + out IntPtr TokenHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool LookupPrivilegeValue(string lpSystemName, string lpName, ref LUID lpLuid); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool AdjustTokenPrivileges( + IntPtr TokenHandle, + bool DisableAllPrivileges, + ref TOKEN_PRIVILEGES NewState, + int BufferLengthInBytes, + IntPtr PreviousState, + IntPtr ReturnLengthInBytes); + + #endregion + + #region Utilities + + // https://docs.microsoft.com/en-us/windows/win32/secauthz/enabling-and-disabling-privileges-in-c-- + public static void SetPrivilege(IntPtr hToken, string lpszPrivilege, bool bEnablePrivilege) + { + LUID luid = new(); + if (!LookupPrivilegeValue(null, lpszPrivilege, ref luid)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "LookupPrivilegeValue failed."); + + TOKEN_PRIVILEGES tp = new() + { + PrivilegeCount = 1, + Privileges = new LUID_AND_ATTRIBUTES[] { + new LUID_AND_ATTRIBUTES{ + Luid = luid, + Attributes = bEnablePrivilege ? SE_PRIVILEGE_ENABLED : 0, + } + }, + }; + if (!AdjustTokenPrivileges(hToken, false, ref tp, Marshal.SizeOf(tp), IntPtr.Zero, IntPtr.Zero)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "AdjustTokenPrivileges failed."); + + if (Marshal.GetLastWin32Error() == ERROR_NOT_ALL_ASSIGNED) + throw new Win32Exception(Marshal.GetLastWin32Error(), "The token does not have the specified privilege."); + } + + public static void SetCurrentPrivilege(string lpszPrivilege, bool bEnablePrivilege) + { + if (!OpenProcessToken(Process.GetCurrentProcess().SafeHandle.DangerousGetHandle(), TOKEN_QUERY | TOKEN_ADJUST_PRIVILEGES, out var hToken)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + + try + { + SetPrivilege(hToken, lpszPrivilege, bEnablePrivilege); + } + finally + { + CloseHandle(hToken); + } + } + + #endregion + } + // ReSharper restore once InconsistentNaming +#endif + + public IndexedZiPatchInstaller(IndexedZiPatchIndex def) + { + Index = def; + foreach (var _ in def.Targets) + { + MissingPartIndicesPerTargetFile.Add(new()); + this.targetStreams.Add(null); + this.targetLocks.Add(new()); + } + foreach (var _ in def.Sources) + MissingPartIndicesPerPatch.Add(new()); + } + + public async Task VerifyFiles(bool refine = false, int concurrentCount = 8, CancellationToken? cancellationToken = null) + { + CancellationTokenSource localCancelSource = new(); + + if (cancellationToken.HasValue) + cancellationToken.Value.Register(() => localCancelSource?.Cancel()); + + SizeMismatchTargetFileIndices.Clear(); + foreach (var l in MissingPartIndicesPerPatch) + l.Clear(); + + List verifyTasks = new(); + try + { + long progressCounter = 0; + long progressMax = refine ? MissingPartIndicesPerTargetFile.Select((x, i) => x.Select(y => Index[i][y].TargetSize).Sum()).Sum() : Index.Targets.Select((x, i) => this.targetStreams[i] == null ? 0 : x.FileSize).Sum(); + + Queue pendingTargetIndices = new(); + for (int i = 0; i < Index.Length; i++) + pendingTargetIndices.Enqueue(i); + + Task progressReportTask = null; + while (verifyTasks.Any() || pendingTargetIndices.Any()) + { + localCancelSource.Token.ThrowIfCancellationRequested(); + + while (pendingTargetIndices.Any() && verifyTasks.Count < concurrentCount) + { + var targetIndex = pendingTargetIndices.Dequeue(); + var stream = this.targetStreams[targetIndex]; + if (stream == null) + continue; + + var file = Index[targetIndex]; + if (stream.Length != file.FileSize) + SizeMismatchTargetFileIndices.Add(targetIndex); + + verifyTasks.Add(Task.Run(() => + { + List targetPartIndicesToCheck; + if (refine) + { + targetPartIndicesToCheck = MissingPartIndicesPerTargetFile[targetIndex].ToList(); + MissingPartIndicesPerTargetFile[targetIndex].Clear(); + } + else + { + targetPartIndicesToCheck = new(); + for (var partIndex = 0; partIndex < file.Count; ++partIndex) + targetPartIndicesToCheck.Add(partIndex); + } + foreach (var partIndex in targetPartIndicesToCheck) + { + localCancelSource.Token.ThrowIfCancellationRequested(); + + var verifyResult = file[partIndex].Verify(stream); + lock (verifyTasks) + { + progressCounter += file[partIndex].TargetSize; + switch (verifyResult) + { + case IndexedZiPatchPartLocator.VerifyDataResult.Pass: + break; + + case IndexedZiPatchPartLocator.VerifyDataResult.FailUnverifiable: + throw new Exception($"{file.RelativePath}:{file[partIndex].TargetOffset}:{file[partIndex].TargetEnd}: Should not happen; unverifiable due to insufficient source data"); + + case IndexedZiPatchPartLocator.VerifyDataResult.FailNotEnoughData: + case IndexedZiPatchPartLocator.VerifyDataResult.FailBadData: + MissingPartIndicesPerTargetFile[file[partIndex].TargetIndex].Add(partIndex); + OnCorruptionFound?.Invoke(file[partIndex], verifyResult); + break; + } + } + } + })); + } + + if (progressReportTask == null || progressReportTask.IsCompleted) + { + progressReportTask = Task.Delay(ProgressReportInterval, localCancelSource.Token); + OnVerifyProgress?.Invoke(Math.Max(0, Index.Length - pendingTargetIndices.Count - verifyTasks.Count - 1), progressCounter, progressMax); + } + + verifyTasks.Add(progressReportTask); + await Task.WhenAny(verifyTasks); + verifyTasks.RemoveAt(verifyTasks.Count - 1); + if (verifyTasks.FirstOrDefault(x => x.IsFaulted) is Task x) + throw x.Exception; + verifyTasks.RemoveAll(x => x.IsCompleted); + } + + for (var targetIndex = 0; targetIndex < Index.Length; targetIndex++) + { + foreach (var partIndex in MissingPartIndicesPerTargetFile[targetIndex]) + { + var part = Index[targetIndex][partIndex]; + if (part.IsFromSourceFile) + MissingPartIndicesPerPatch[part.SourceIndex].Add(Tuple.Create(targetIndex, partIndex)); + } + } + } + finally + { + localCancelSource.Cancel(); + foreach (var task in verifyTasks) + { + if (task.IsCompleted) + continue; + try + { + await task; + } + catch (Exception) + { + // ignore + } + } + localCancelSource.Dispose(); + localCancelSource = null; + } + } + + public void MarkFileAsMissing(int targetIndex) + { + var file = Index[targetIndex]; + for (var i = 0; i < file.Count; ++i) + MissingPartIndicesPerTargetFile[targetIndex].Add(i); + } + + public void SetTargetStreamForRead(int targetIndex, Stream targetStream) + { + if (!targetStream.CanRead || !targetStream.CanSeek) + throw new ArgumentException("Target stream must be readable and seekable."); + + this.targetStreams[targetIndex] = targetStream; + } + + public void SetTargetStreamForWriteFromFile(int targetIndex, FileInfo fileInfo, bool useSetFileValidData = false) + { + var file = Index[targetIndex]; + fileInfo.Directory.Create(); + var stream = fileInfo.Open(FileMode.OpenOrCreate, FileAccess.ReadWrite); + + if (stream.Length != file.FileSize) + { + stream.Seek(file.FileSize, SeekOrigin.Begin); + stream.SetLength(file.FileSize); + +#if WIN32 + if (useSetFileValidData && !PInvoke.SetFileValidData(stream.SafeFileHandle.DangerousGetHandle(), file.FileSize)) + Log.Information($"Unable to apply SetFileValidData on file {fileInfo.FullName} (error code {Marshal.GetLastWin32Error()})"); +#endif + } + + this.targetStreams[targetIndex] = stream; + } + + public void SetTargetStreamsFromPathReadOnly(string rootPath) + { + Dispose(); + for (var i = 0; i < Index.Length; i++) + { + var file = Index[i]; + var fileInfo = new FileInfo(Path.Combine(rootPath, file.RelativePath)); + if (fileInfo.Exists) + SetTargetStreamForRead(i, new FileStream(Path.Combine(rootPath, file.RelativePath), FileMode.Open, FileAccess.Read)); + else + MarkFileAsMissing(i); + } + } + + public void SetTargetStreamsFromPathReadWriteForMissingFiles(string rootPath) + { + Dispose(); + +#if WIN32 + var useSetFileValidData = true; + try + { + PInvoke.SetCurrentPrivilege("SeManageVolumePrivilege", true); + } + catch (Win32Exception e) + { + Log.Information(e, "Unable to obtain SeManageVolumePrivilege; not using SetFileValidData."); + useSetFileValidData = false; + } +#else + var useSetFileValidData = false; +#endif + + for (var i = 0; i < Index.Length; i++) + { + if (MissingPartIndicesPerTargetFile[i].Count == 0 && !SizeMismatchTargetFileIndices.Contains(i)) + continue; + + var file = Index[i]; + var fileInfo = new FileInfo(Path.Combine(rootPath, file.RelativePath)); + SetTargetStreamForWriteFromFile(i, fileInfo, useSetFileValidData); + } + } + + private void WriteToTarget(int targetIndex, long targetOffset, byte[] buffer, int offset, int count) + { + var target = this.targetStreams[targetIndex]; + if (target == null) + return; + + lock (this.targetLocks[targetIndex]) + { + target.Seek(targetOffset, SeekOrigin.Begin); + target.Write(buffer, offset, count); + target.Flush(); + } + } + + public async Task RepairNonPatchData(CancellationToken? cancellationToken = null) + { + await Task.Run(() => + { + for (int i = 0, length = Index.Length; i < length; i++) + { + if (cancellationToken.HasValue) + cancellationToken.Value.ThrowIfCancellationRequested(); + + var file = Index[i]; + foreach (var partIndex in MissingPartIndicesPerTargetFile[i]) + { + if (cancellationToken.HasValue) + cancellationToken.Value.ThrowIfCancellationRequested(); + + var part = file[partIndex]; + if (part.IsFromSourceFile) + continue; + + using var buffer = ReusableByteBufferManager.GetBuffer(part.TargetSize); + part.ReconstructWithoutSourceData(buffer.Buffer); + WriteToTarget(i, part.TargetOffset, buffer.Buffer, 0, (int)part.TargetSize); + } + } + }); + } + + public void WriteVersionFiles(string localRootPath) + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.Combine(localRootPath, Index.VersionFileVer))); + using (var writer = new StreamWriter(new FileStream(Path.Combine(localRootPath, Index.VersionFileVer), FileMode.Create, FileAccess.Write))) + writer.Write(Index.VersionName); + using (var writer = new StreamWriter(new FileStream(Path.Combine(localRootPath, Index.VersionFileBck), FileMode.Create, FileAccess.Write))) + writer.Write(Index.VersionName); + } + + public abstract class InstallTaskConfig : IDisposable + { + public long ProgressMax { get; protected set; } + public long ProgressValue { get; protected set; } + public readonly IndexedZiPatchIndex Index; + public readonly IndexedZiPatchInstaller Installer; + public readonly int SourceIndex; + public readonly List> TargetPartIndices; + public readonly List> CompletedTargetPartIndices = new(); + public InstallTaskState State { get; protected set; } = InstallTaskState.NotStarted; + + public InstallTaskConfig(IndexedZiPatchInstaller installer, int sourceIndex, IEnumerable> targetPartIndices) + { + Index = installer.Index; + Installer = installer; + SourceIndex = sourceIndex; + TargetPartIndices = targetPartIndices.ToList(); + } + + public abstract Task Repair(CancellationToken cancellationToken); + + public virtual void Dispose() { } + } + + public class HttpInstallTaskConfig : InstallTaskConfig + { + private static readonly int[] ReattemptWait = new int[] { 0, 500, 1000, 2000, 3000, 5000, 10000, 15000, 20000, 25000, 30000, 45000, 60000 }; + private const int MERGED_GAP_DOWNLOAD = 512; + + public readonly string SourceUrl; + private readonly HttpClient client = new(); + private readonly List targetPartOffsets; + private readonly string sid; + + public HttpInstallTaskConfig(IndexedZiPatchInstaller installer, int sourceIndex, IEnumerable> targetPartIndices, string sourceUrl, string sid) + : base(installer, sourceIndex, targetPartIndices) + { + SourceUrl = sourceUrl; + this.sid = sid; + TargetPartIndices.Sort((x, y) => Index[x.Item1][x.Item2].SourceOffset.CompareTo(Index[y.Item1][y.Item2].SourceOffset)); + this.targetPartOffsets = TargetPartIndices.Select(x => Index[x.Item1][x.Item2].SourceOffset).ToList(); + + foreach (var (targetIndex, partIndex) in TargetPartIndices) + ProgressMax += Index[targetIndex][partIndex].TargetSize; + } + + private MultipartResponseHandler multipartResponse = null; + + private async Task GetNextStream(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (this.multipartResponse != null) + { + var stream1 = await this.multipartResponse.NextPart(cancellationToken); + if (stream1 != null) + return stream1; + + this.multipartResponse?.Dispose(); + this.multipartResponse = null; + } + + var offsets = new List>(); + offsets.Clear(); + foreach (var (targetIndex, partIndex) in TargetPartIndices) + offsets.Add(Tuple.Create(Index[targetIndex][partIndex].SourceOffset, Math.Min(Index.GetSourceLastPtr(SourceIndex), Index[targetIndex][partIndex].MaxSourceEnd))); + offsets.Sort(); + + for (int i = 1; i < offsets.Count; i++) + { + if (offsets[i].Item1 - offsets[i - 1].Item2 >= MERGED_GAP_DOWNLOAD) + continue; + offsets[i - 1] = Tuple.Create(offsets[i - 1].Item1, Math.Max(offsets[i - 1].Item2, offsets[i].Item2)); + offsets.RemoveAt(i); + i -= 1; + } + + using HttpRequestMessage req = new(HttpMethod.Get, SourceUrl); + req.Headers.Range = new(); + req.Headers.Range.Unit = "bytes"; + foreach (var (rangeFrom, rangeToExclusive) in offsets) + req.Headers.Range.Ranges.Add(new(rangeFrom, rangeToExclusive + 1)); + if (this.sid != null) + req.Headers.Add("X-Patch-Unique-Id", this.sid); + req.Headers.Add("User-Agent", Constants.PatcherUserAgent); + req.Headers.Add("Connection", "Keep-Alive"); + + try + { + var resp = await this.client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + this.multipartResponse = new MultipartResponseHandler(resp); + } + catch (HttpRequestException e) + { + throw new IOException($"Failed to send request to {SourceUrl} with {offsets.Count} range element(s).", e); + } + + var stream2 = await this.multipartResponse.NextPart(cancellationToken); + if (stream2 == null) + throw new EndOfStreamException("Encountered premature end of stream"); + return stream2; + } + + public override async Task Repair(CancellationToken cancellationToken) + { + for (int failedCount = 0; TargetPartIndices.Any() && failedCount < ReattemptWait.Length;) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + + State = InstallTaskState.WaitingForReattempt; + await Task.Delay(ReattemptWait[failedCount], cancellationToken); + + State = InstallTaskState.Connecting; + var stream = await GetNextStream(cancellationToken); + + State = InstallTaskState.Working; + while (this.targetPartOffsets.Any()) + { + cancellationToken.ThrowIfCancellationRequested(); + + var (targetIndex, partIndex) = TargetPartIndices.First(); + var part = Index[targetIndex][partIndex]; + + if (Math.Min(part.MaxSourceEnd, Index.GetSourceLastPtr(SourceIndex)) > stream.OriginEnd) + break; + + using var targetBuffer = ReusableByteBufferManager.GetBuffer(part.TargetSize); + part.Reconstruct(stream, targetBuffer.Buffer); + Installer.WriteToTarget(part.TargetIndex, part.TargetOffset, targetBuffer.Buffer, 0, (int)part.TargetSize); + failedCount = 0; + + ProgressValue += part.TargetSize; + CompletedTargetPartIndices.Add(TargetPartIndices.First()); + TargetPartIndices.RemoveAt(0); + this.targetPartOffsets.RemoveAt(0); + } + } + catch (IOException ex) + { + if (failedCount >= 8) + Log.Error(ex, "HttpInstallTask failed"); + else + Log.Warning(ex, "HttpInstallTask reattempting"); + + failedCount++; + if (failedCount == ReattemptWait.Length) + { + State = InstallTaskState.Error; + throw; + } + } + catch (Exception) + { + State = InstallTaskState.Error; + throw; + } + } + + State = InstallTaskState.Done; + } + + public override void Dispose() + { + this.multipartResponse?.Dispose(); + this.client.Dispose(); + base.Dispose(); + } + } + + public class StreamInstallTaskConfig : InstallTaskConfig + { + public readonly Stream SourceStream; + public readonly IList> SourceOffsets; + + public StreamInstallTaskConfig(IndexedZiPatchInstaller installer, int sourceIndex, IEnumerable> targetPartIndices, Stream sourceStream) + : base(installer, sourceIndex, targetPartIndices) + { + SourceStream = sourceStream; + long totalTargetSize = 0; + foreach (var (targetIndex, partIndex) in TargetPartIndices) + totalTargetSize += Index[targetIndex][partIndex].TargetSize; + ProgressMax = totalTargetSize; + } + + public override async Task Repair(CancellationToken cancellationToken) + { + State = InstallTaskState.Working; + try + { + await Task.Run(() => + { + while (TargetPartIndices.Any()) + { + cancellationToken.ThrowIfCancellationRequested(); + + var (targetIndex, partIndex) = TargetPartIndices.First(); + var part = Index[targetIndex][partIndex]; + + using var buffer = ReusableByteBufferManager.GetBuffer(part.TargetSize); + part.Reconstruct(SourceStream, buffer.Buffer); + Installer.WriteToTarget(part.TargetIndex, part.TargetOffset, buffer.Buffer, 0, (int)part.TargetSize); + + ProgressValue += part.TargetSize; + CompletedTargetPartIndices.Add(TargetPartIndices.First()); + TargetPartIndices.RemoveAt(0); + } + }); + State = InstallTaskState.Done; + } + catch (Exception) + { + State = InstallTaskState.Error; + } + } + + public override void Dispose() + { + SourceStream.Dispose(); + base.Dispose(); + } + } + + private readonly List installTaskConfigs = new(); + + public void QueueInstall(int sourceIndex, string sourceUrl, string sid, ISet> targetPartIndices) + { + if (targetPartIndices.Any()) + this.installTaskConfigs.Add(new HttpInstallTaskConfig(this, sourceIndex, targetPartIndices, sourceUrl, sid == "" ? null : sid)); + } + + public void QueueInstall(int sourceIndex, string sourceUrl, string sid, int splitBy = 8) + { + const int MAX_DOWNLOAD_PER_REQUEST = 256 * 1024 * 1024; + + var indices = MissingPartIndicesPerPatch[sourceIndex].ToList(); + var indicesPerRequest = (int)Math.Ceiling(1.0 * indices.Count / splitBy); + for (int j = 0; j < indices.Count;) + { + SortedSet> targetPartIndices = new(); + long size = 0; + for (; j < indices.Count && targetPartIndices.Count < indicesPerRequest && size < MAX_DOWNLOAD_PER_REQUEST; ++j) + { + targetPartIndices.Add(indices[j]); + size += Index[indices[j].Item1][indices[j].Item2].MaxSourceSize; + } + QueueInstall(sourceIndex, sourceUrl, sid, targetPartIndices); + } + } + + public void QueueInstall(int sourceIndex, Stream stream, ISet> targetPartIndices) + { + if (targetPartIndices.Any()) + this.installTaskConfigs.Add(new StreamInstallTaskConfig(this, sourceIndex, targetPartIndices, stream)); + } + + public void QueueInstall(int sourceIndex, FileInfo file, ISet> targetPartIndices) + { + if (targetPartIndices.Any()) + QueueInstall(sourceIndex, file.OpenRead(), targetPartIndices); + } + + public void QueueInstall(int sourceIndex, FileInfo file, int splitBy = 8) + { + var indices = MissingPartIndicesPerPatch[sourceIndex]; + var indicesPerRequest = (int)Math.Ceiling(1.0 * indices.Count / splitBy); + for (int j = 0; j < indices.Count; j += indicesPerRequest) + QueueInstall(sourceIndex, file, new HashSet>(indices.Skip(j).Take(Math.Min(indicesPerRequest, indices.Count - j)))); // This was .ToHashSet(), but .NET Standard 2.0 doesn't have it + } + + public async Task Install(int concurrentCount, CancellationToken? cancellationToken = null) + { + if (!this.installTaskConfigs.Any()) + { + await RepairNonPatchData(); + return; + } + + long progressMax = this.installTaskConfigs.Select(x => x.ProgressMax).Sum(); + + CancellationTokenSource localCancelSource = new(); + + if (cancellationToken.HasValue) + cancellationToken.Value.Register(() => localCancelSource?.Cancel()); + + Task progressReportTask = null; + Queue pendingTaskConfigs = new(); + foreach (var x in this.installTaskConfigs) + pendingTaskConfigs.Enqueue(x); + + List runningTasks = new(); + + try + { + while (pendingTaskConfigs.Any() || runningTasks.Any()) + { + localCancelSource.Token.ThrowIfCancellationRequested(); + + while (pendingTaskConfigs.Any() && runningTasks.Count < concurrentCount) + runningTasks.Add(pendingTaskConfigs.Dequeue().Repair(localCancelSource.Token)); + + OnInstallProgress?.Invoke( + this.installTaskConfigs[Math.Max(0, this.installTaskConfigs.Count - pendingTaskConfigs.Count - runningTasks.Count - 1)].SourceIndex, + this.installTaskConfigs.Select(x => x.ProgressValue).Sum(), + progressMax, + this.installTaskConfigs.Where(x => x.State < InstallTaskState.Finishing).Select(x => x.State).Max() + ); + + if (progressReportTask == null || progressReportTask.IsCompleted) + progressReportTask = Task.Delay(ProgressReportInterval, localCancelSource.Token); + runningTasks.Add(progressReportTask); + await Task.WhenAny(runningTasks); + runningTasks.RemoveAt(runningTasks.Count - 1); + + if (runningTasks.FirstOrDefault(x => x.IsFaulted) is Task x) + throw x.Exception; + runningTasks.RemoveAll(x => x.IsCompleted); + } + + OnInstallProgress?.Invoke(this.installTaskConfigs.Last().SourceIndex, progressMax, progressMax, InstallTaskState.Finishing); + await RepairNonPatchData(); + } + finally + { + localCancelSource.Cancel(); + foreach (var task in runningTasks) + { + if (task.IsCompleted) + continue; + try + { + await task; + } + catch (Exception) + { + // ignore + } + } + localCancelSource.Dispose(); + localCancelSource = null; + } + } + + public void Dispose() + { + for (var i = 0; i < this.targetStreams.Count; i++) + { + if (this.targetStreams[i] != null) + { + this.targetStreams[i].Dispose(); + this.targetStreams[i] = null; + } + } + foreach (var item in this.installTaskConfigs) + item.Dispose(); + this.installTaskConfigs.Clear(); + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchOperations.cs b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchOperations.cs new file mode 100644 index 0000000..ccce5b1 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchOperations.cs @@ -0,0 +1,174 @@ +using Serilog; +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using XIVLauncher2.Common.Patching.ZiPatch; + +namespace XIVLauncher2.Common.Patching.IndexedZiPatch +{ + public class IndexedZiPatchOperations + { + public static async Task CreateZiPatchIndices(int expacVersion, IList patchFilePaths, CancellationToken? cancellationToken = null) + { + var sources = new List(); + var patchFiles = new List(); + var patchIndex = new IndexedZiPatchIndex(expacVersion); + try + { + var firstPatchFileIndex = patchFilePaths.Count - 1; + while (firstPatchFileIndex > 0) + { + if (File.Exists(patchFilePaths[firstPatchFileIndex] + ".index")) + break; + firstPatchFileIndex--; + } + for (var i = 0; i < patchFilePaths.Count; ++i) + { + if (cancellationToken.HasValue) + cancellationToken.Value.ThrowIfCancellationRequested(); + + var patchFilePath = patchFilePaths[i]; + sources.Add(new FileStream(patchFilePath, FileMode.Open, FileAccess.Read)); + patchFiles.Add(new ZiPatchFile(sources[sources.Count - 1])); + + if (i < firstPatchFileIndex) + continue; + + if (File.Exists(patchFilePath + ".index")) + { + Log.Information("Reading patch index file {0}...", patchFilePath); + patchIndex = new(new BinaryReader(new DeflateStream(new FileStream(patchFilePath + ".index", FileMode.Open, FileAccess.Read), CompressionMode.Decompress))); + continue; + } + + Log.Information("Indexing patch file {0}...", patchFilePath); + await patchIndex.ApplyZiPatch(Path.GetFileName(patchFilePath), patchFiles[patchFiles.Count - 1], cancellationToken); + + Log.Information("Calculating CRC32 for files resulted from patch file {0}...", patchFilePath); + await patchIndex.CalculateCrc32(sources, cancellationToken); + + using (var writer = new BinaryWriter(new DeflateStream(new FileStream(patchFilePath + ".index.tmp", FileMode.Create), CompressionLevel.Optimal))) + patchIndex.WriteTo(writer); + + File.Move(patchFilePath + ".index.tmp", patchFilePath + ".index"); + } + + return patchIndex; + } + finally + { + foreach (var source in sources) + source.Dispose(); + } + } + + public static async Task VerifyFromZiPatchIndex(string patchIndexFilePath, string gameRootPath, CancellationToken? cancellationToken = null) => await VerifyFromZiPatchIndex(new IndexedZiPatchIndex(new BinaryReader(new DeflateStream(new FileStream(patchIndexFilePath, FileMode.Open, FileAccess.Read), CompressionMode.Decompress))), gameRootPath, cancellationToken); + + public static async Task VerifyFromZiPatchIndex(IndexedZiPatchIndex patchIndex, string gameRootPath, CancellationToken? cancellationToken = null) + { + using var verifier = new IndexedZiPatchInstaller(patchIndex) + { + ProgressReportInterval = 1000 + }; + + var remainingErrorMessagesToShow = 8; + void OnVerifyProgressCallback(int index, long progress, long max) => Log.Information("[{0}/{1}] Checking file {2}... {3:0.00}/{4:0.00}MB ({5:00.00}%)", index + 1, patchIndex.Length, patchIndex[Math.Min(index, patchIndex.Length - 1)].RelativePath, progress / 1048576.0, max / 1048576.0, 100.0 * progress / max); ; + void OnCorruptionFoundCallback(IndexedZiPatchPartLocator part, IndexedZiPatchPartLocator.VerifyDataResult result) + { + switch (result) + { + case IndexedZiPatchPartLocator.VerifyDataResult.FailNotEnoughData: + if (remainingErrorMessagesToShow > 0) + { + Log.Error("{0}:{1}:{2}: Premature EOF detected", patchIndex[part.TargetIndex].RelativePath, part.TargetOffset, patchIndex[part.TargetIndex].FileSize); + remainingErrorMessagesToShow = 0; + } + break; + + case IndexedZiPatchPartLocator.VerifyDataResult.FailBadData: + if (remainingErrorMessagesToShow > 0) + { + if (--remainingErrorMessagesToShow == 0) + Log.Warning("{0}:{1}:{2}: Corrupt data; suppressing further corruption warnings for this file.", patchIndex[part.TargetIndex].RelativePath, part.TargetOffset, part.TargetEnd); + else + Log.Warning("{0}:{1}:{2}: Corrupt data", patchIndex[part.TargetIndex].RelativePath, part.TargetOffset, part.TargetEnd); + } + break; + } + }; + + verifier.OnVerifyProgress += OnVerifyProgressCallback; + verifier.OnCorruptionFound += OnCorruptionFoundCallback; + + try + { + verifier.SetTargetStreamsFromPathReadOnly(gameRootPath); + await verifier.VerifyFiles(false, 8, cancellationToken); + } + finally + { + verifier.OnVerifyProgress -= OnVerifyProgressCallback; + verifier.OnCorruptionFound -= OnCorruptionFoundCallback; + } + + return verifier; + } + + public static async Task RepairFromPatchFileIndexFromFile(IndexedZiPatchIndex patchIndex, string gameRootPath, string patchFileRootDir, int concurrentCount, CancellationToken? cancellationToken = null) + { + using var verifier = await VerifyFromZiPatchIndex(patchIndex, gameRootPath, cancellationToken); + verifier.SetTargetStreamsFromPathReadWriteForMissingFiles(gameRootPath); + for (var i = 0; i < patchIndex.Sources.Count; i++) + verifier.QueueInstall(i, new FileInfo(Path.Combine(patchFileRootDir, patchIndex.Sources[i]))); + await verifier.Install(concurrentCount, cancellationToken); + } + + public static async Task RepairFromPatchFileIndexFromFile(string patchIndexFilePath, string gameRootPath, string patchFileRootDir, int concurrentCount, CancellationToken? cancellationToken = null) => await RepairFromPatchFileIndexFromFile(new IndexedZiPatchIndex(new BinaryReader(new DeflateStream(new FileStream(patchIndexFilePath, FileMode.Open, FileAccess.Read), CompressionMode.Decompress))), gameRootPath, patchFileRootDir, concurrentCount, cancellationToken); + + public static async Task RepairFromPatchFileIndexFromUri(IndexedZiPatchIndex patchIndex, string gameRootPath, string baseUri, int concurrentCount, CancellationToken? cancellationToken = null) + { + using var verifier = await VerifyFromZiPatchIndex(patchIndex, gameRootPath, cancellationToken); + verifier.SetTargetStreamsFromPathReadWriteForMissingFiles(gameRootPath); + for (var i = 0; i < patchIndex.Sources.Count; i++) + verifier.QueueInstall(i, baseUri + patchIndex.Sources[i], null, concurrentCount); + + void OnInstallProgressCallback(int index, long progress, long max, IndexedZiPatchInstaller.InstallTaskState state) => Log.Information("[{0}/{1}] {2} {3}... {4:0.00}/{5:0.00}MB ({6:00.00}%)", index, patchIndex.Sources.Count, state, patchIndex.Sources[Math.Min(index, patchIndex.Sources.Count - 1)], progress / 1048576.0, max / 1048576.0, 100.0 * progress / max); + verifier.OnInstallProgress += OnInstallProgressCallback; + try + { + await verifier.Install(concurrentCount, cancellationToken); + verifier.WriteVersionFiles(gameRootPath); + } + finally + { + verifier.OnInstallProgress -= OnInstallProgressCallback; + } + } + + public static async Task RepairFromPatchFileIndexFromUri(string patchIndexFilePath, string gameRootPath, string baseUri, int concurrentCount, CancellationToken? cancellationToken = null) => await RepairFromPatchFileIndexFromUri(new IndexedZiPatchIndex(new BinaryReader(new DeflateStream(new FileStream(patchIndexFilePath, FileMode.Open, FileAccess.Read), CompressionMode.Decompress))), gameRootPath, baseUri, concurrentCount, cancellationToken); + + private static async Task Test_Single(int expacVersion, string patchFilesPath, string rootPath, string baseUri, CancellationToken? cancellationToken = null) + { + var patchFiles = Directory.GetFiles(Directory.GetDirectories(patchFilesPath).Where(x => Path.GetFileName(x).Length == 8).First(), "*.patch").ToList(); + patchFiles.Sort((x, y) => Path.GetFileName(x).Substring(1).CompareTo(Path.GetFileName(y).Substring(1))); + var patchIndex = await CreateZiPatchIndices(expacVersion, patchFiles, cancellationToken); + await RepairFromPatchFileIndexFromUri(patchIndex, rootPath, baseUri, 8, cancellationToken); + } + + public static void Test() + { + CancellationTokenSource source = new(); + string[] patchFileBaseUrls = new string[] { + "http://patch-dl.ffxiv.com/boot/2b5cbc63/", + }; + // source.Cancel(); + Task.WaitAll(new Task[] { + Test_Single(IndexedZiPatchIndex.EXPAC_VERSION_BOOT, @"Z:\patch-dl.ffxiv.com\boot", @"Z:\tgame\boot", patchFileBaseUrls[0], source.Token), + }); + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchPartLocator.cs b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchPartLocator.cs new file mode 100644 index 0000000..a3ba4b8 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchPartLocator.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Runtime.InteropServices; +using XIVLauncher2.Common.Patching.Util; + +namespace XIVLauncher2.Common.Patching.IndexedZiPatch +{ + [StructLayout(LayoutKind.Sequential)] + [Serializable] + public struct IndexedZiPatchPartLocator : IComparable + { + public const byte SOURCE_INDEX_ZEROS = byte.MaxValue - 0; + public const byte SOURCE_INDEX_EMPTY_BLOCK = byte.MaxValue - 1; + public const byte SOURCE_INDEX_UNAVAILABLE = byte.MaxValue - 2; + public const byte SOURCE_INDEX_MAX_VALID = byte.MaxValue - 3; + + private const uint TARGET_SIZE_AND_FLAG_MASK_IS_DEFLATED_BLOCK_DATA = 0x80000000; + private const uint TARGET_SIZE_AND_FLAG_MASK_IS_VALID_CRC32_VALUE = 0x40000000; + private const uint TARGET_SIZE_AND_FLAG_MASK_TARGET_SIZE = 0x3FFFFFFF; + + private uint TargetOffsetUint; // up to 35 bits, using only 32 bits (28 bits for locator + lsh 7; odd values exist), but currently .dat# files are delimited at 1.9GB + private uint SourceOffsetUint; // up to 31 bits (patch files were delimited at 1.5GB-ish; odd values exist) + private uint TargetSizeAndFlags; // 2 flag bits + up to 31 size bits, using only 30 bits (same with above) + public uint Crc32OrPlaceholderEntryDataUnits; // fixed 32 bits + private ushort SplitDecodedSourceFromUshort; // up to 14 bits (max value 15999) + private byte TargetIndexByte; // using only 8 bits for now + private byte SourceIndexByte; // using only 8 bits for now + + public long TargetOffset + { + get => TargetOffsetUint; + set => TargetOffsetUint = CheckedCastToUint(value); + } + + public long SourceOffset + { + get => SourceOffsetUint; + set => SourceOffsetUint = CheckedCastToUint(value); + } + + public long TargetSize + { + get => TargetSizeAndFlags & TARGET_SIZE_AND_FLAG_MASK_TARGET_SIZE; + set => TargetSizeAndFlags = CheckedCastToUint((TargetSizeAndFlags & ~TARGET_SIZE_AND_FLAG_MASK_TARGET_SIZE) | value, TARGET_SIZE_AND_FLAG_MASK_TARGET_SIZE); + } + + public long SplitDecodedSourceFrom + { + get => SplitDecodedSourceFromUshort; + set => SplitDecodedSourceFromUshort = CheckedCastToUshort(value); + } + + public int TargetIndex + { + get => TargetIndexByte; + set => TargetIndexByte = CheckedCastToByte(value); + } + + public int SourceIndex + { + get => SourceIndexByte; + set => SourceIndexByte = CheckedCastToByte(value); + } + + public long MaxSourceSize => IsDeflatedBlockData ? 16384 : TargetSize; + public long MaxSourceEnd => SourceOffset + MaxSourceSize; + public long TargetEnd => TargetOffset + TargetSize; + + public bool IsDeflatedBlockData + { + get => 0 != (TargetSizeAndFlags & TARGET_SIZE_AND_FLAG_MASK_IS_DEFLATED_BLOCK_DATA); + set => TargetSizeAndFlags = (TargetSizeAndFlags & ~TARGET_SIZE_AND_FLAG_MASK_IS_DEFLATED_BLOCK_DATA) | (value ? TARGET_SIZE_AND_FLAG_MASK_IS_DEFLATED_BLOCK_DATA : 0u); + } + + public bool IsValidCrc32Value + { + get => 0 != (TargetSizeAndFlags & TARGET_SIZE_AND_FLAG_MASK_IS_VALID_CRC32_VALUE); + set => TargetSizeAndFlags = (TargetSizeAndFlags & ~TARGET_SIZE_AND_FLAG_MASK_IS_VALID_CRC32_VALUE) | (value ? TARGET_SIZE_AND_FLAG_MASK_IS_VALID_CRC32_VALUE : 0u); + } + + public bool IsAllZeros => SourceIndex == SOURCE_INDEX_ZEROS; + public bool IsEmptyBlock => SourceIndex == SOURCE_INDEX_EMPTY_BLOCK; + public bool IsUnavailable => SourceIndex == SOURCE_INDEX_UNAVAILABLE; + public bool IsFromSourceFile => !IsAllZeros && !IsEmptyBlock && !IsUnavailable; + + public void WriteTo(BinaryWriter writer) + { + writer.Write(this.TargetOffsetUint); + writer.Write(this.SourceOffsetUint); + writer.Write(this.TargetSizeAndFlags); + writer.Write(this.Crc32OrPlaceholderEntryDataUnits); + writer.Write(this.SplitDecodedSourceFromUshort); + writer.Write(this.TargetIndexByte); + writer.Write(this.SourceIndexByte); + } + + public void ReadFrom(BinaryReader reader) + { + this.TargetOffsetUint = reader.ReadUInt32(); + this.SourceOffsetUint = reader.ReadUInt32(); + this.TargetSizeAndFlags = reader.ReadUInt32(); + this.Crc32OrPlaceholderEntryDataUnits = reader.ReadUInt32(); + this.SplitDecodedSourceFromUshort = reader.ReadUInt16(); + this.TargetIndexByte = reader.ReadByte(); + this.SourceIndexByte = reader.ReadByte(); + } + + public int CompareTo(IndexedZiPatchPartLocator other) + { + var x = TargetOffset - other.TargetOffset; + return x < 0 ? -1 : x > 0 ? 1 : 0; + } + + public enum VerifyDataResult + { + Pass, + FailUnverifiable, + FailNotEnoughData, + FailBadData, + } + + public VerifyDataResult Verify(byte[] buf, int offset, int length) + { + if (length != TargetSize) + return VerifyDataResult.FailNotEnoughData; + + if (IsValidCrc32Value) + return Crc32.Calculate(buf, offset, length) == Crc32OrPlaceholderEntryDataUnits ? VerifyDataResult.Pass : VerifyDataResult.FailBadData; + + if (IsAllZeros) + return buf.Skip(offset).Take(length).All(x => x == 0) ? VerifyDataResult.Pass : VerifyDataResult.FailBadData; + + if (IsEmptyBlock) + { + return BitConverter.ToInt32(buf, offset + 0) == 1 << 7 + && BitConverter.ToInt32(buf, offset + 4) == 0 + && BitConverter.ToInt32(buf, offset + 8) == 0 + && BitConverter.ToInt32(buf, offset + 12) == this.Crc32OrPlaceholderEntryDataUnits + && BitConverter.ToInt32(buf, offset + 16) == 0 + && BitConverter.ToInt32(buf, offset + 20) == 0 + && buf.Skip(offset + 24).Take(length - 24).All(x => x == 0) + ? VerifyDataResult.Pass + : VerifyDataResult.FailBadData; + } + + return VerifyDataResult.FailUnverifiable; + } + + public VerifyDataResult Verify(Stream stream, bool seek = true) + { + using var buffer = ReusableByteBufferManager.GetBuffer(); + if (seek) + stream.Seek(TargetOffset, SeekOrigin.Begin); + + if (IsValidCrc32Value) + { + Crc32 crc32 = new(); + + for (var remaining = TargetSize; remaining > 0; remaining -= buffer.Buffer.Length) + { + var readSize = (int)Math.Min(remaining, buffer.Buffer.Length); + if (readSize != stream.Read(buffer.Buffer, 0, readSize)) + return VerifyDataResult.FailNotEnoughData; + + crc32.Update(buffer.Buffer, 0, readSize); + } + + if (crc32.Checksum != Crc32OrPlaceholderEntryDataUnits) + return VerifyDataResult.FailBadData; + + return VerifyDataResult.Pass; + } + else if (IsAllZeros) + { + for (var remaining = TargetSize; remaining > 0; remaining -= buffer.Buffer.Length) + { + var readSize = (int)Math.Min(remaining, buffer.Buffer.Length); + if (readSize != stream.Read(buffer.Buffer, 0, readSize)) + return VerifyDataResult.FailNotEnoughData; + if (!buffer.Buffer.Take(readSize).All(x => x == 0)) + return VerifyDataResult.FailBadData; + } + + return VerifyDataResult.Pass; + } + else if (IsEmptyBlock) + { + var readSize = Math.Min(1 << 7, buffer.Buffer.Length); + if (readSize != stream.Read(buffer.Buffer, 0, readSize)) + return VerifyDataResult.FailNotEnoughData; + + // File entry header for placeholder + if (BitConverter.ToInt32(buffer.Buffer, 0) != 1 << 7 + || BitConverter.ToInt32(buffer.Buffer, 4) != 0 + || BitConverter.ToInt32(buffer.Buffer, 8) != 0 + || BitConverter.ToInt32(buffer.Buffer, 12) != Crc32OrPlaceholderEntryDataUnits + || BitConverter.ToInt32(buffer.Buffer, 16) != 0 + || BitConverter.ToInt32(buffer.Buffer, 20) != 0 + || !buffer.Buffer.Skip(24).Take(readSize - 24).All(x => x == 0)) + return VerifyDataResult.FailBadData; + + return VerifyDataResult.Pass; + } + + return VerifyDataResult.FailUnverifiable; + } + + public int Reconstruct(IList sources, byte[] buffer, int bufferOffset = 0, int bufferSize = -1, int relativeOffset = 0, bool verify = true) + { + if (IsFromSourceFile) + return Reconstruct(sources[SourceIndex], buffer, bufferOffset, bufferSize, relativeOffset, verify); + + return Reconstruct(null, 0, 0, buffer, bufferOffset, bufferSize, relativeOffset, verify); + } + + private int FilterBufferSize(byte[] buffer, int bufferOffset, int bufferSize, int relativeOffset) + { + if (bufferSize == -1) + return (int)Math.Max(0, Math.Min(TargetSize - relativeOffset, buffer.Length - bufferOffset)); + else if (bufferSize > TargetSize - relativeOffset) + return (int)Math.Max(0, TargetSize - relativeOffset); + else if (bufferSize < 0) + throw new ArgumentException("Length cannot be less than zero."); + else + return bufferSize; + } + + public int ReconstructWithoutSourceData(byte[] buffer, int bufferOffset = 0, int bufferSize = -1, int relativeOffset = 0) + { + bufferSize = FilterBufferSize(buffer, bufferOffset, bufferSize, relativeOffset); + if (bufferSize == 0) + return 0; + + if (IsUnavailable) + throw new InvalidOperationException("Unavailable part read attempt"); + else if (IsAllZeros) + Array.Clear(buffer, bufferOffset, bufferSize); + else if (IsEmptyBlock) + { + Array.Clear(buffer, bufferOffset, bufferSize); + + if (relativeOffset < 16) + { + using var buffer2 = ReusableByteBufferManager.GetBuffer(); + buffer2.Writer.Write(1 << 7); + buffer2.Writer.Write(0); + buffer2.Writer.Write(0); + buffer2.Writer.Write((int)Crc32OrPlaceholderEntryDataUnits); + buffer2.Writer.Write(0); + buffer2.Writer.Write(0); + Array.Copy(buffer2.Buffer, relativeOffset, buffer, bufferOffset, Math.Min(bufferSize, 24 - relativeOffset)); + } + } + else + throw new InvalidOperationException("This part requires source data."); + + return bufferSize; + } + + public int Reconstruct(byte[] sourceSegment, int sourceSegmentOffset, int sourceSegmentLength, byte[] buffer, int bufferOffset = 0, int bufferSize = -1, int relativeOffset = 0, bool verify = true) + { + if (!IsFromSourceFile) + return ReconstructWithoutSourceData(buffer, bufferOffset, bufferSize, relativeOffset); + + bufferSize = FilterBufferSize(buffer, bufferOffset, bufferSize, relativeOffset); + if (bufferSize == 0) + return 0; + + + if (IsDeflatedBlockData) + { + using var inflatedBuffer = ReusableByteBufferManager.GetBuffer(MaxSourceSize); + using (var stream = new DeflateStream(new MemoryStream(sourceSegment, sourceSegmentOffset, sourceSegmentLength - sourceSegmentOffset), CompressionMode.Decompress, true)) + stream.FullRead(inflatedBuffer.Buffer, 0, inflatedBuffer.Buffer.Length); + if (verify && VerifyDataResult.Pass != Verify(inflatedBuffer.Buffer, (int)SplitDecodedSourceFrom, (int)TargetSize)) + throw new IOException("Verify failed on reconstruct (inflate)"); + + Array.Copy(inflatedBuffer.Buffer, SplitDecodedSourceFrom + relativeOffset, buffer, bufferOffset, bufferSize); + } + else + { + if (sourceSegmentLength - sourceSegmentOffset < TargetSize) + throw new IOException("Insufficient source data"); + if (verify && VerifyDataResult.Pass != Verify(sourceSegment, (int)(sourceSegmentOffset + SplitDecodedSourceFrom), (int)TargetSize)) + throw new IOException("Verify failed on reconstruct"); + + Array.Copy(sourceSegment, sourceSegmentOffset + SplitDecodedSourceFrom + relativeOffset, buffer, bufferOffset, bufferSize); + } + + return bufferSize; + } + + public int Reconstruct(Stream source, byte[] buffer, int bufferOffset = 0, int bufferSize = -1, int relativeOffset = 0, bool verify = true) + { + if (!IsFromSourceFile) + return ReconstructWithoutSourceData(buffer, bufferOffset, bufferSize, relativeOffset); + + bufferSize = FilterBufferSize(buffer, bufferOffset, bufferSize, relativeOffset); + if (bufferSize == 0) + return 0; + + source.Seek(SourceOffset, SeekOrigin.Begin); + var readSize = (int)(IsDeflatedBlockData ? 16384 : TargetSize); + using var readBuffer = ReusableByteBufferManager.GetBuffer(readSize); + var read = source.Read(readBuffer.Buffer, 0, readSize); + return Reconstruct(readBuffer.Buffer, 0, read, buffer, bufferOffset, bufferSize, relativeOffset, verify); + } + + public static void CalculateCrc32(ref IndexedZiPatchPartLocator part, Stream source) + { + if (part.IsValidCrc32Value) + return; + + using var buffer = ReusableByteBufferManager.GetBuffer(part.TargetSize); + if (part.TargetSize != part.Reconstruct(source, buffer.Buffer, 0, (int)part.TargetSize, 0, false)) + throw new EndOfStreamException("Encountered premature end of file while trying to read the source stream."); + + part.Crc32OrPlaceholderEntryDataUnits = Crc32.Calculate(buffer.Buffer, 0, (int)part.TargetSize); + part.IsValidCrc32Value = true; + } + + private static uint CheckedCastToUint(long v, long maxValue = uint.MaxValue) + { + if (v > maxValue) + throw new ArgumentException("Value too big"); + + return (uint)v; + } + + private static ushort CheckedCastToUshort(long v, long maxValue = ushort.MaxValue) + { + if (v > maxValue) + throw new ArgumentException("Value too big"); + + return (ushort)v; + } + + private static byte CheckedCastToByte(long v, long maxValue = byte.MaxValue) + { + if (v > maxValue) + throw new ArgumentException("Value too big"); + + return (byte)v; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchTargetFile.cs b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchTargetFile.cs new file mode 100644 index 0000000..e4f7511 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchTargetFile.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace XIVLauncher2.Common.Patching.IndexedZiPatch +{ + public partial class IndexedZiPatchTargetFile : IList + { + public string RelativePath = ""; + private readonly List underlying = new(); + + public IndexedZiPatchTargetFile() : base() { } + + public IndexedZiPatchTargetFile(string fileName) : base() { + RelativePath = fileName; + } + + public IndexedZiPatchTargetFile(BinaryReader reader, bool disposeReader = true) : base() + { + try + { + ReadFrom(reader); + } + finally + { + if (disposeReader) + reader.Dispose(); + } + } + + public IndexedZiPatchPartLocator this[int index] { get => this.underlying[index]; set => this.underlying[index] = value; } + + public int Count => this.underlying.Count; + + public bool IsReadOnly => false; + + public void Add(IndexedZiPatchPartLocator item) => this.underlying.Add(item); + + public void Clear() => this.underlying.Clear(); + + public bool Contains(IndexedZiPatchPartLocator item) => this.underlying.Contains(item); + + public void CopyTo(IndexedZiPatchPartLocator[] array, int arrayIndex) => this.underlying.CopyTo(array, arrayIndex); + + public IEnumerator GetEnumerator() => this.underlying.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => this.underlying.GetEnumerator(); + + public int IndexOf(IndexedZiPatchPartLocator item) => this.underlying.IndexOf(item); + + public void Insert(int index, IndexedZiPatchPartLocator item) => this.underlying.Insert(index, item); + + public bool Remove(IndexedZiPatchPartLocator item) => this.underlying.Remove(item); + + public void RemoveAt(int index) => this.underlying.RemoveAt(index); + + public long FileSize => this.underlying.Count > 0 ? this.underlying.Last().TargetEnd : 0; + + public int BinarySearchByTargetOffset(long targetOffset) + { + return this.underlying.BinarySearch(new IndexedZiPatchPartLocator { TargetOffset = targetOffset }); ; + } + + public void SplitAt(long offset, int targetFileIndex) + { + var i = BinarySearchByTargetOffset(offset); + if (i >= 0) + { + // Already split at given offset + return; + } + + i = ~i; + if (i == 0 && offset == 0) + { + // Do nothing; split at 0 is a given + } + else if (i == 0 && this.underlying.Count == 0) + { + this.underlying.Add(new IndexedZiPatchPartLocator + { + TargetSize = offset, + TargetIndex = targetFileIndex, + SourceIndex = IndexedZiPatchPartLocator.SOURCE_INDEX_ZEROS, + }); + } + else if (i == this.underlying.Count && this.underlying[i - 1].TargetEnd == offset) + { + // Do nothing; split at TargetEnd of last part is give + } + else if (i == this.underlying.Count && this.underlying[i - 1].TargetEnd < offset) + { + this.underlying.Add(new IndexedZiPatchPartLocator + { + TargetOffset = this.underlying[i - 1].TargetEnd, + TargetSize = offset - this.underlying[i - 1].TargetEnd, + TargetIndex = targetFileIndex, + SourceIndex = IndexedZiPatchPartLocator.SOURCE_INDEX_ZEROS, + }); + } + else + { + i -= 1; + var part = this.underlying[i]; + + if (part.IsDeflatedBlockData || part.IsEmptyBlock) + { + this.underlying[i] = new IndexedZiPatchPartLocator + { + TargetOffset = part.TargetOffset, + TargetSize = offset - part.TargetOffset, + TargetIndex = targetFileIndex, + SourceIndex = part.SourceIndex, + SourceOffset = part.SourceOffset, + SplitDecodedSourceFrom = part.SplitDecodedSourceFrom, + Crc32OrPlaceholderEntryDataUnits = part.Crc32OrPlaceholderEntryDataUnits, + IsDeflatedBlockData = part.IsDeflatedBlockData, + }; + this.underlying.Insert(i + 1, new IndexedZiPatchPartLocator + { + TargetOffset = offset, + TargetSize = part.TargetEnd - offset, + TargetIndex = targetFileIndex, + SourceIndex = part.SourceIndex, + SourceOffset = part.SourceOffset, + SplitDecodedSourceFrom = part.SplitDecodedSourceFrom + offset - part.TargetOffset, + Crc32OrPlaceholderEntryDataUnits = part.Crc32OrPlaceholderEntryDataUnits, + IsDeflatedBlockData = part.IsDeflatedBlockData, + }); + } + else + { + if (part.SplitDecodedSourceFrom != 0) + throw new ArgumentException("Not deflated but SplitDecodeSourceFrom is given"); + + this.underlying[i] = new IndexedZiPatchPartLocator + { + TargetOffset = part.TargetOffset, + TargetSize = offset - part.TargetOffset, + TargetIndex = targetFileIndex, + SourceIndex = part.SourceIndex, + SourceOffset = part.SourceOffset, + Crc32OrPlaceholderEntryDataUnits = part.Crc32OrPlaceholderEntryDataUnits, + }; + this.underlying.Insert(i + 1, new IndexedZiPatchPartLocator + { + TargetOffset = offset, + TargetSize = part.TargetEnd - offset, + TargetIndex = targetFileIndex, + SourceIndex = part.SourceIndex, + SourceOffset = part.SourceOffset + offset - part.TargetOffset, + Crc32OrPlaceholderEntryDataUnits = part.Crc32OrPlaceholderEntryDataUnits, + }); + } + } + } + + public void Update(IndexedZiPatchPartLocator part) + { + if (part.TargetSize == 0) + return; + + SplitAt(part.TargetOffset, part.TargetIndex); + SplitAt(part.TargetEnd, part.TargetIndex); + + var left = BinarySearchByTargetOffset(part.TargetOffset); + if (left < 0) + left = ~left; + + if (left == this.underlying.Count) + { + this.underlying.Add(part); + return; + } + + var right = BinarySearchByTargetOffset(part.TargetEnd); + if (right < 0) + right = ~right; + + if (right - left - 1 < 0) + Debugger.Break(); + + this.underlying[left] = part; + this.underlying.RemoveRange(left + 1, right - left - 1); + } + + public async Task CalculateCrc32(List sources, CancellationToken? cancellationToken = null) + { + await Task.Run(() => + { + var list = this.underlying.ToArray(); + for (var i = 0; i < list.Length; ++i) + { + if (cancellationToken.HasValue) + cancellationToken.Value.ThrowIfCancellationRequested(); + if (list[i].IsFromSourceFile) + IndexedZiPatchPartLocator.CalculateCrc32(ref list[i], sources[list[i].SourceIndex]); + } + this.underlying.Clear(); + this.underlying.AddRange(list); + }); + } + + public Stream ToStream(List sources) + { + return new IndexedZiPatchTargetViewStream(sources, this); + } + + public void WriteTo(BinaryWriter writer) + { + writer.Write(RelativePath); + writer.Write(this.underlying.Count); + foreach (var item in this.underlying) + item.WriteTo(writer); + } + + public void ReadFrom(BinaryReader reader) + { + RelativePath = reader.ReadString(); + var dest = new IndexedZiPatchPartLocator[reader.ReadInt32()]; + for (var i = 0; i < dest.Length; ++i) + dest[i].ReadFrom(reader); + this.underlying.Clear(); + this.underlying.AddRange(dest); + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchTargetViewStream.cs b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchTargetViewStream.cs new file mode 100644 index 0000000..f06045d --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/IndexedZiPatch/IndexedZiPatchTargetViewStream.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace XIVLauncher2.Common.Patching.IndexedZiPatch +{ + public class IndexedZiPatchTargetViewStream : Stream + { + private readonly List sources; + private readonly IndexedZiPatchTargetFile partList; + + internal IndexedZiPatchTargetViewStream(List sources, IndexedZiPatchTargetFile partList) + { + this.sources = sources; + this.partList = partList; + } + + public override bool CanRead => true; + + public override bool CanSeek => true; + + public override bool CanWrite => false; + + public override long Length => this.partList.FileSize; + + public override long Position { get; set; } + + public override int Read(byte[] buffer, int offset, int count) + { + var beginOffset = offset; + while (count > 0 && Position < Length) + { + var i = this.partList.BinarySearchByTargetOffset(Position); + if (i < 0) + i = ~i - 1; + + var reconstructedLength = this.partList[i].Reconstruct(this.sources, buffer, offset, count, (int)(Position - this.partList[i].TargetOffset)); + offset += reconstructedLength; + count -= reconstructedLength; + Position += reconstructedLength; + } + return offset - beginOffset; + } + + public override long Seek(long offset, SeekOrigin origin) + { + var position = Position; + switch (origin) + { + case SeekOrigin.Begin: + position = offset; + break; + + case SeekOrigin.Current: + position += offset; + break; + + case SeekOrigin.End: + position = Length - offset; + break; + + default: + throw new NotImplementedException(); + } + + if (position < 0) + throw new ArgumentException("Seeking is attempted before the beginning of the stream."); + + Position = Math.Min(position, Length); + + return Position; + } + + public override void Flush() => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/src/XIVLauncher2.Common/Patching/RemotePatchInstaller.cs b/src/XIVLauncher2.Common/Patching/RemotePatchInstaller.cs new file mode 100644 index 0000000..caca568 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/RemotePatchInstaller.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using XIVLauncher2.Common.PatcherIpc; +using XIVLauncher2.Common.Patching.Rpc; +using XIVLauncher2.Common.Patching.ZiPatch; +using XIVLauncher2.Common.Patching.ZiPatch.Util; + +namespace XIVLauncher2.Common.Patching; + +public class RemotePatchInstaller +{ + private readonly IRpc rpc; + private readonly ConcurrentQueue queuedInstalls = new(); + private readonly Thread patcherThread; + private readonly CancellationTokenSource patcherCancelToken = new(); + + public bool IsDone { get; private set; } + + public bool IsFailed { get; private set; } + + public bool HasQueuedInstalls => !this.queuedInstalls.IsEmpty; + + public RemotePatchInstaller(IRpc rpc) + { + this.rpc = rpc; + this.rpc.MessageReceived += RemoteCallHandler; + + Log.Information("[PATCHER] IPC connected"); + + rpc.SendMessage(new PatcherIpcEnvelope + { + OpCode = PatcherIpcOpCode.Hello, + Timestamp = DateTime.Now + }); + + Log.Information("[PATCHER] sent hello"); + + this.patcherThread = new Thread(this.ProcessPatches); + } + + public void Start() + { + this.patcherThread.Start(); + } + + private void ProcessPatches() + { + try + { + while (!this.patcherCancelToken.IsCancellationRequested) + { + if (!RunInstallQueue()) + { + IsFailed = true; + return; + } + + Thread.Sleep(1000); + } + } + catch (Exception ex) + { + Log.Error(ex, "[PATCHER] RemotePatchInstaller loop encountered an error"); + this.rpc.SendMessage(new PatcherIpcEnvelope + { + OpCode = PatcherIpcOpCode.InstallFailed + }); + } + } + + private void RemoteCallHandler(PatcherIpcEnvelope envelope) + { + switch (envelope.OpCode) + { + case PatcherIpcOpCode.Bye: + Task.Run(() => + { + Thread.Sleep(3000); + IsDone = true; + }); + break; + + case PatcherIpcOpCode.StartInstall: + + var installData = envelope.StartInstallInfo; + this.queuedInstalls.Enqueue(installData); + break; + + case PatcherIpcOpCode.Finish: + var path = envelope.GameDirectory; + + try + { + VerToBck(path); + Log.Information("VerToBck done"); + } + catch (Exception ex) + { + Log.Error(ex, "VerToBck failed"); + this.rpc.SendMessage(new PatcherIpcEnvelope + { + OpCode = PatcherIpcOpCode.InstallFailed + }); + } + + break; + } + } + + private bool RunInstallQueue() + { + if (this.queuedInstalls.TryDequeue(out var installData)) + { + // Ensure that subdirs exist + if (!installData.GameDirectory.Exists) + installData.GameDirectory.Create(); + + installData.GameDirectory.CreateSubdirectory("game"); + installData.GameDirectory.CreateSubdirectory("boot"); + + try + { + InstallPatch(installData.PatchFile.FullName, + Path.Combine(installData.GameDirectory.FullName, + installData.Repo == Repository.Boot ? "boot" : "game")); + + try + { + installData.Repo.SetVer(installData.GameDirectory, installData.VersionId); + this.rpc.SendMessage(new PatcherIpcEnvelope + { + OpCode = PatcherIpcOpCode.InstallOk + }); + + try + { + if (!installData.KeepPatch) + installData.PatchFile.Delete(); + } + catch (Exception exception) + { + Log.Error(exception, "Could not delete patch file"); + } + } + catch (Exception ex) + { + Log.Error(ex, "Could not set ver file"); + this.rpc.SendMessage(new PatcherIpcEnvelope + { + OpCode = PatcherIpcOpCode.InstallFailed + }); + + return false; + } + } + catch (Exception ex) + { + Log.Error(ex, "[PATCHER] Patch install failed"); + this.rpc.SendMessage(new PatcherIpcEnvelope + { + OpCode = PatcherIpcOpCode.InstallFailed + }); + + return false; + } + } + + return true; + } + + public static void InstallPatch(string patchPath, string gamePath) + { + Log.Information("[PATCHER] Installing {0} to {1}", patchPath, gamePath); + + using var patchFile = ZiPatchFile.FromFileName(patchPath); + + using (var store = new SqexFileStreamStore()) + { + var config = new ZiPatchConfig(gamePath) { Store = store }; + + foreach (var chunk in patchFile.GetChunks()) + chunk.ApplyChunk(config); + } + + Log.Information("[PATCHER] Patch {0} installed", patchPath); + } + + private static void VerToBck(DirectoryInfo gamePath) + { + Thread.Sleep(500); + + foreach (var repository in Enum.GetValues(typeof(Repository)).Cast()) + { + // Overwrite the old BCK with the new game version + var ver = repository.GetVer(gamePath); + + try + { + repository.SetVer(gamePath, ver, true); + } + catch (Exception ex) + { + Log.Error(ex, "[PATCHER] Could not copy to BCK"); + + if (ver != Constants.BASE_GAME_VERSION) + throw; + } + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/Rpc/IRpc.cs b/src/XIVLauncher2.Common/Patching/Rpc/IRpc.cs new file mode 100644 index 0000000..0d1f7f8 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/Rpc/IRpc.cs @@ -0,0 +1,11 @@ +using System; +using XIVLauncher2.Common.PatcherIpc; + +namespace XIVLauncher2.Common.Patching.Rpc; + +public interface IRpc +{ + public void SendMessage(PatcherIpcEnvelope envelope); + + public event Action MessageReceived; +} diff --git a/src/XIVLauncher2.Common/Patching/Rpc/Implementations/InProcessRpc.cs b/src/XIVLauncher2.Common/Patching/Rpc/Implementations/InProcessRpc.cs new file mode 100644 index 0000000..1aacd81 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/Rpc/Implementations/InProcessRpc.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using XIVLauncher2.Common.PatcherIpc; + +namespace XIVLauncher2.Common.Patching.Rpc.Implementations; + +public class InProcessRpc : IRpc, IDisposable +{ + private readonly string channelName; + + private static readonly Dictionary> instanceMapping = new(); + + public InProcessRpc(string channelName) + { + this.channelName = channelName; + + if (!instanceMapping.TryGetValue(channelName, out var instanceList)) + { + instanceList = new List(); + instanceMapping.Add(channelName, instanceList); + } + + instanceList.Add(this); + } + + public void SendMessage(PatcherIpcEnvelope envelope) + { + var list = instanceMapping[this.channelName]; + + for (var i = 0; i < list.Count; i++) + { + var otherInstance = list[i]; + + if (otherInstance == this) + continue; + + otherInstance.Dispatch(envelope); + } + } + + private void Dispatch(PatcherIpcEnvelope envelope) + { + this.MessageReceived?.Invoke(envelope); + } + + public event Action MessageReceived; + + public void Dispose() + { + instanceMapping[this.channelName].Remove(this); + } +} diff --git a/src/XIVLauncher2.Common/Patching/Rpc/Implementations/SharedMemoryRpc.cs b/src/XIVLauncher2.Common/Patching/Rpc/Implementations/SharedMemoryRpc.cs new file mode 100644 index 0000000..e3afc00 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/Rpc/Implementations/SharedMemoryRpc.cs @@ -0,0 +1,41 @@ +using System; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Serilog; +using SharedMemory; +using XIVLauncher2.Common.PatcherIpc; + +namespace XIVLauncher2.Common.Patching.Rpc.Implementations; + +public class SharedMemoryRpc : IRpc, IDisposable +{ + private readonly RpcBuffer rpcBuffer; + + public SharedMemoryRpc(string channelName) + { + this.rpcBuffer = new RpcBuffer(channelName, RemoteCallHandler); + } + + private void RemoteCallHandler(ulong msgId, byte[] payload) + { + var json = IpcHelpers.Base64Decode(Encoding.ASCII.GetString(payload)); + Log.Information("[SHMEMRPC] IPC({0}): {1}", msgId, json); + + var msg = JsonSerializer.Deserialize(json); + MessageReceived?.Invoke(msg); + } + + public void SendMessage(PatcherIpcEnvelope envelope) + { + var json = IpcHelpers.Base64Encode(JsonSerializer.Serialize(envelope)); + this.rpcBuffer.RemoteRequest(Encoding.ASCII.GetBytes(json)); + } + + public event Action MessageReceived; + + public void Dispose() + { + rpcBuffer?.Dispose(); + } +} diff --git a/src/XIVLauncher2.Common/Patching/Rpc/IpcHelpers.cs b/src/XIVLauncher2.Common/Patching/Rpc/IpcHelpers.cs new file mode 100644 index 0000000..9192abd --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/Rpc/IpcHelpers.cs @@ -0,0 +1,16 @@ +namespace XIVLauncher2.Common.PatcherIpc; + +public static class IpcHelpers +{ + public static string Base64Encode(string plainText) + { + var plainTextBytes = System.Text.Encoding.UTF8.GetBytes(plainText); + return System.Convert.ToBase64String(plainTextBytes); + } + + public static string Base64Decode(string base64EncodedData) + { + var base64EncodedBytes = System.Convert.FromBase64String(base64EncodedData); + return System.Text.Encoding.UTF8.GetString(base64EncodedBytes); + } +} diff --git a/src/XIVLauncher2.Common/Patching/Rpc/PatcherIpcEnvelope.cs b/src/XIVLauncher2.Common/Patching/Rpc/PatcherIpcEnvelope.cs new file mode 100644 index 0000000..0558021 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/Rpc/PatcherIpcEnvelope.cs @@ -0,0 +1,22 @@ +#nullable enable +using System; +using System.IO; +using System.Text.Json.Serialization; + +namespace XIVLauncher2.Common.PatcherIpc +{ + public class PatcherIpcEnvelope + { + public PatcherIpcOpCode OpCode { get; set; } + public DateTime? Timestamp { get; set; } + public PatcherIpcStartInstall? StartInstallInfo { get; set; } + public string? GameDirectoryPath { get; set; } + + [JsonIgnore] + public DirectoryInfo? GameDirectory + { + get => new(GameDirectoryPath!); + set => GameDirectoryPath = value?.FullName; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/Rpc/PatcherIpcOpCode.cs b/src/XIVLauncher2.Common/Patching/Rpc/PatcherIpcOpCode.cs new file mode 100644 index 0000000..383e0db --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/Rpc/PatcherIpcOpCode.cs @@ -0,0 +1,13 @@ +namespace XIVLauncher2.Common.PatcherIpc +{ + public enum PatcherIpcOpCode + { + Hello, + Bye, + StartInstall, + InstallRunning, + InstallOk, + InstallFailed, + Finish + } +} diff --git a/src/XIVLauncher2.Common/Patching/Rpc/PatcherIpcStartInstall.cs b/src/XIVLauncher2.Common/Patching/Rpc/PatcherIpcStartInstall.cs new file mode 100644 index 0000000..46bbeaa --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/Rpc/PatcherIpcStartInstall.cs @@ -0,0 +1,29 @@ +#nullable enable +using System.IO; +using System.Text.Json.Serialization; + +namespace XIVLauncher2.Common.PatcherIpc +{ + public class PatcherIpcStartInstall + { + public Repository Repo { get; set; } + public string VersionId { get; set; } = null!; + public bool KeepPatch { get; set; } + public string? PatchFilePath { get; set; } + public string? GameDirectoryPath { get; set; } + + [JsonIgnore] + public FileInfo? PatchFile + { + get => new(PatchFilePath!); + set => PatchFilePath = value?.FullName; + } + + [JsonIgnore] + public DirectoryInfo? GameDirectory + { + get => new(GameDirectoryPath!); + set => GameDirectoryPath = value?.FullName; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/Util/BinaryReaderHelpers.cs b/src/XIVLauncher2.Common/Patching/Util/BinaryReaderHelpers.cs new file mode 100644 index 0000000..9897c34 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/Util/BinaryReaderHelpers.cs @@ -0,0 +1,126 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +// ReSharper disable InconsistentNaming + +namespace XIVLauncher2.Common.Patching.Util +{ + // https://stackoverflow.com/a/15274591 + static class BinaryReaderHelpers + { + + public static string ReadFixedLengthString(this BinaryReader reader, uint length) + { + return Encoding.ASCII.GetString(reader.ReadBytesRequired((int)length)).TrimEnd((char)0); + } + + // Note this MODIFIES THE GIVEN ARRAY then returns a reference to the modified array. + public static byte[] Reverse(this byte[] b) + { + Array.Reverse(b); + return b; + } + + public static UInt16 ReadUInt16BE(this BinaryReader binRdr) + { + return BitConverter.ToUInt16(binRdr.ReadBytesRequired(sizeof(UInt16)).Reverse(), 0); + } + + public static Int16 ReadInt16BE(this BinaryReader binRdr) + { + return BitConverter.ToInt16(binRdr.ReadBytesRequired(sizeof(Int16)).Reverse(), 0); + } + + public static UInt32 ReadUInt32BE(this BinaryReader binRdr) + { + return BitConverter.ToUInt32(binRdr.ReadBytesRequired(sizeof(UInt32)).Reverse(), 0); + } + + public static Int32 ReadInt32BE(this BinaryReader binRdr) + { + return BitConverter.ToInt32(binRdr.ReadBytesRequired(sizeof(Int32)).Reverse(), 0); + } + + public static UInt64 ReadUInt64BE(this BinaryReader binRdr) + { + return BitConverter.ToUInt64(binRdr.ReadBytesRequired(sizeof(UInt64)).Reverse(), 0); + } + + public static Int64 ReadInt64BE(this BinaryReader binRdr) + { + return BitConverter.ToInt64(binRdr.ReadBytesRequired(sizeof(Int64)).Reverse(), 0); + } + + public static byte[] ReadBytesRequired(this BinaryReader binRdr, int byteCount) + { + var result = binRdr.ReadBytes(byteCount); + + if (result.Length != byteCount) + throw new EndOfStreamException($"{byteCount} bytes required from stream, but only {result.Length} returned."); + + return result; + } + + public static void Dump(this byte[] bytes, int offset = 0, int bytesPerLine = 16) + { + var hexChars = "0123456789ABCDEF".ToCharArray(); + + var offsetBlock = 8 + 3; + var byteBlock = offsetBlock + bytesPerLine * 3 + (bytesPerLine - 1) / 8 + 2; + var lineLength = byteBlock + bytesPerLine + Environment.NewLine.Length; + + var line = (new string(' ', lineLength - Environment.NewLine.Length) + Environment.NewLine).ToCharArray(); + var numLines = (bytes.Length + bytesPerLine - 1) / bytesPerLine; + + var sb = new StringBuilder(numLines * lineLength); + sb.Append('\n'); + + for (var i = 0; i < bytes.Length; i += bytesPerLine) + { + var h = i + offset; + + line[0] = hexChars[(h >> 28) & 0xF]; + line[1] = hexChars[(h >> 24) & 0xF]; + line[2] = hexChars[(h >> 20) & 0xF]; + line[3] = hexChars[(h >> 16) & 0xF]; + line[4] = hexChars[(h >> 12) & 0xF]; + line[5] = hexChars[(h >> 8) & 0xF]; + line[6] = hexChars[(h >> 4) & 0xF]; + line[7] = hexChars[(h >> 0) & 0xF]; + + var hexColumn = offsetBlock; + var charColumn = byteBlock; + + for (var j = 0; j < bytesPerLine; j++) + { + if (j > 0 && (j & 7) == 0) + { + hexColumn++; + } + + if (i + j >= bytes.Length) + { + line[hexColumn] = ' '; + line[hexColumn + 1] = ' '; + line[charColumn] = ' '; + } + else + { + var by = bytes[i + j]; + line[hexColumn] = hexChars[(by >> 4) & 0xF]; + line[hexColumn + 1] = hexChars[by & 0xF]; + line[charColumn] = by < 32 ? '.' : (char)by; + } + + hexColumn += 3; + charColumn++; + } + + sb.Append(line); + } + + Debug.WriteLine(sb.ToString().TrimEnd(Environment.NewLine.ToCharArray())); + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/Util/ChecksumBinaryReader.cs b/src/XIVLauncher2.Common/Patching/Util/ChecksumBinaryReader.cs new file mode 100644 index 0000000..cf1d0c4 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/Util/ChecksumBinaryReader.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; + +namespace XIVLauncher2.Common.Patching.Util +{ + public class ChecksumBinaryReader : BinaryReader + { + private readonly Crc32 _crc32 = new Crc32(); + + public ChecksumBinaryReader(Stream input) : base(input) {} + + + public void InitCrc32() + { + _crc32.Init(); + } + + public uint GetCrc32() + { + return _crc32.Checksum; + } + + public override byte[] ReadBytes(int count) + { + var result = base.ReadBytes(count); + + _crc32.Update(result); + + return result; + } + + public override byte ReadByte() + { + var result = base.ReadByte(); + + _crc32.Update(result); + + return result; + } + + public override sbyte ReadSByte() => (sbyte)ReadByte(); + public override bool ReadBoolean() => ReadByte() != 0; + public override char ReadChar() => (char)ReadByte(); + public override short ReadInt16() => BitConverter.ToInt16(ReadBytes(sizeof(short)), 0); + public override ushort ReadUInt16() => BitConverter.ToUInt16(ReadBytes(sizeof(ushort)), 0); + public override int ReadInt32() => BitConverter.ToInt32(ReadBytes(sizeof(int)), 0); + public override uint ReadUInt32() => BitConverter.ToUInt32(ReadBytes(sizeof(uint)), 0); + public override long ReadInt64() => BitConverter.ToInt64(ReadBytes(sizeof(long)), 0); + public override ulong ReadUInt64() => BitConverter.ToUInt64(ReadBytes(sizeof(ulong)), 0); + public override float ReadSingle() => BitConverter.ToSingle(ReadBytes(sizeof(float)), 0); + public override double ReadDouble() => BitConverter.ToDouble(ReadBytes(sizeof(float)), 0); + } +} diff --git a/src/XIVLauncher2.Common/Patching/Util/CircularMemoryStream.cs b/src/XIVLauncher2.Common/Patching/Util/CircularMemoryStream.cs new file mode 100644 index 0000000..b658482 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/Util/CircularMemoryStream.cs @@ -0,0 +1,268 @@ +using System; +using System.IO; + +namespace XIVLauncher2.Common.Patching.Util +{ + public class CircularMemoryStream : Stream + { + public enum FeedOverflowMode + { + ExtendCapacity, + DiscardOldest, + Throw, + } + + private readonly FeedOverflowMode overflowMode; + private ReusableByteBufferManager.Allocation reusableBuffer; + private int bufferValidTo = 0; + private int bufferValidFrom = 0; + private int length = 0; + private int externalPosition = 0; + + public CircularMemoryStream(int baseCapacity = 0, FeedOverflowMode feedOverflowMode = FeedOverflowMode.ExtendCapacity) + { + this.overflowMode = feedOverflowMode; + if (feedOverflowMode == FeedOverflowMode.ExtendCapacity && baseCapacity == 0) + this.reusableBuffer = ReusableByteBufferManager.GetBuffer(); + else + this.reusableBuffer = ReusableByteBufferManager.GetBuffer(baseCapacity); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + this.reusableBuffer?.Dispose(); + } + + public void Reserve(long capacity) + { + if (capacity <= Capacity) + return; + + var newBuffer = ReusableByteBufferManager.GetBuffer(capacity); + if (this.length > 0) + { + if (this.bufferValidFrom < this.bufferValidTo) + Array.Copy(this.reusableBuffer.Buffer, this.bufferValidFrom, newBuffer.Buffer, 0, this.length); + else + { + Array.Copy(this.reusableBuffer.Buffer, this.bufferValidFrom, newBuffer.Buffer, 0, Capacity - this.bufferValidFrom); + Array.Copy(this.reusableBuffer.Buffer, 0, newBuffer.Buffer, Capacity - this.bufferValidFrom, this.bufferValidTo); + } + } + + this.reusableBuffer.Dispose(); + this.reusableBuffer = newBuffer; + + this.bufferValidFrom = 0; + this.bufferValidTo = this.length; + } + + public void Feed(byte[] buffer, int offset, int count) + { + if (count == 0) + return; + + if (this.length + count > Capacity) + { + switch (this.overflowMode) + { + case FeedOverflowMode.ExtendCapacity: + Reserve(Length + count); + break; + + case FeedOverflowMode.DiscardOldest: + if (count >= Capacity) + { + this.bufferValidFrom = 0; + this.bufferValidTo = 0; + Array.Copy(buffer, offset + count - Capacity, this.reusableBuffer.Buffer, 0, Capacity); + this.externalPosition = 0; + this.length = Capacity; + return; + } + Consume(null, 0, this.length + count - Capacity); + break; + + case FeedOverflowMode.Throw: + throw new InvalidOperationException($"Cannot feed {count} bytes (length={Length}, capacity={Capacity})"); + } + } + + if (this.bufferValidFrom < this.bufferValidTo) + { + var rightLength = Capacity - this.bufferValidTo; + if (rightLength >= count) + Buffer.BlockCopy(buffer, offset, this.reusableBuffer.Buffer, this.bufferValidTo, count); + else + { + Buffer.BlockCopy(buffer, offset, this.reusableBuffer.Buffer, this.bufferValidTo, rightLength); + Buffer.BlockCopy(buffer, offset + rightLength, this.reusableBuffer.Buffer, 0, count - rightLength); + } + } + else + Buffer.BlockCopy(buffer, offset, this.reusableBuffer.Buffer, this.bufferValidTo, count); + + this.bufferValidTo = (this.bufferValidTo + count) % Capacity; + this.length += count; + } + + public int Consume(byte[] buffer, int offset, int count, bool peek = false) + { + count = Math.Min(count, this.length); + if (buffer != null && count > 0) + { + if (this.bufferValidFrom < this.bufferValidTo) + Buffer.BlockCopy(this.reusableBuffer.Buffer, this.bufferValidFrom, buffer, offset, count); + else + { + int rightLength = Capacity - this.bufferValidFrom; + if (rightLength >= count) + Buffer.BlockCopy(this.reusableBuffer.Buffer, this.bufferValidFrom, buffer, offset, count); + else + { + Buffer.BlockCopy(this.reusableBuffer.Buffer, this.bufferValidFrom, buffer, offset, rightLength); + Buffer.BlockCopy(this.reusableBuffer.Buffer, 0, buffer, offset + rightLength, count - rightLength); + } + } + } + if (!peek) + { + this.length -= count; + if (this.length == 0) + this.bufferValidFrom = this.bufferValidTo = 0; + else + this.bufferValidFrom = (this.bufferValidFrom + count) % Capacity; + this.externalPosition = Math.Max(0, this.externalPosition - count); + } + return count; + } + + public byte this[long i] + { + get + { + if (i < 0 || i >= Length) + throw new ArgumentOutOfRangeException(nameof(i)); + return this.reusableBuffer.Buffer[(this.bufferValidFrom + i) % Capacity]; + } + set + { + if (i < 0 || i >= Length) + throw new ArgumentOutOfRangeException(nameof(i)); + this.reusableBuffer.Buffer[(this.bufferValidFrom + i) % Capacity] = value; + } + } + + public int Capacity => this.reusableBuffer.Buffer.Length; + + public override bool CanRead => true; + public override bool CanSeek => true; + public override bool CanWrite => true; + public override long Length => this.length; + public override long Position + { + get => this.externalPosition; + set => Seek(value, SeekOrigin.Begin); + } + + public override void Flush() { } + + public override void SetLength(long value) + { + if (value > int.MaxValue) + throw new ArgumentOutOfRangeException("Length can be up to int.MaxValue"); + if (value == 0) + { + this.bufferValidFrom = this.bufferValidTo = this.length = 0; + return; + } + + var intValue = (int)value; + if (intValue > Capacity) + Reserve(intValue); + else if (intValue > Length) + { + var extendLength = (int)(intValue - Length); + var newValidTo = (this.bufferValidTo + extendLength) % Capacity; + + if (this.bufferValidTo < newValidTo) + Array.Clear(this.reusableBuffer.Buffer, this.bufferValidTo, newValidTo - this.bufferValidTo); + else + { + Array.Clear(this.reusableBuffer.Buffer, this.bufferValidTo, Capacity - this.bufferValidTo); + Array.Clear(this.reusableBuffer.Buffer, 0, newValidTo); + } + + this.bufferValidTo = newValidTo; + } + else if (intValue < Length) + this.bufferValidTo = (this.bufferValidFrom + intValue) % Capacity; + this.length = (int)value; + } + + public override int Read(byte[] buffer, int offset, int count) + { + count = Math.Min(count, this.length - this.externalPosition); + + var adjValidFrom = (this.bufferValidFrom + this.externalPosition) % Capacity; + if (adjValidFrom < this.bufferValidTo) + Buffer.BlockCopy(this.reusableBuffer.Buffer, adjValidFrom, buffer, offset, count); + else + { + int rightLength = Capacity - adjValidFrom; + if (rightLength >= count) + Buffer.BlockCopy(this.reusableBuffer.Buffer, adjValidFrom, buffer, offset, count); + else + { + Buffer.BlockCopy(this.reusableBuffer.Buffer, adjValidFrom, buffer, offset, rightLength); + Buffer.BlockCopy(this.reusableBuffer.Buffer, 0, buffer, offset + rightLength, count - rightLength); + } + } + + this.externalPosition += count; + return count; + } + + public override long Seek(long offset, SeekOrigin origin) + { + long newPosition = this.externalPosition; + switch (origin) + { + case SeekOrigin.Begin: + newPosition = offset; + break; + case SeekOrigin.Current: + newPosition += offset; + break; + case SeekOrigin.End: + newPosition = Length - offset; + break; + } + if (newPosition < 0) + throw new ArgumentException("Seeking is attempted before the beginning of the stream."); + if (newPosition > this.length) + newPosition = this.length; + this.externalPosition = (int)newPosition; + return newPosition; + } + + public override void Write(byte[] buffer, int offset, int count) + { + if (Length + count > Capacity) + Reserve((int)(Length + count)); + + var writeOffset = (this.bufferValidFrom + this.externalPosition) % Capacity; + if (writeOffset + count <= Capacity) + Array.Copy(buffer, offset, this.reusableBuffer.Buffer, writeOffset, count); + else + { + var writeCount1 = Capacity - writeOffset; + var writeCount2 = count - writeCount1; + Array.Copy(buffer, offset, this.reusableBuffer.Buffer, writeOffset, writeCount1); + Array.Copy(buffer, offset + writeCount1, this.reusableBuffer.Buffer, 0, writeCount2); + } + this.externalPosition += count; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/Util/Crc32.cs b/src/XIVLauncher2.Common/Patching/Util/Crc32.cs new file mode 100644 index 0000000..16a07a1 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/Util/Crc32.cs @@ -0,0 +1,67 @@ +using System.Linq; + +namespace XIVLauncher2.Common.Patching.Util +{ + /// + /// Performs the 32-bit reversed variant of the cyclic redundancy check algorithm + /// + public class Crc32 + { + private const uint POLY = 0xedb88320; + + private static readonly uint[] CrcArray = + Enumerable.Range(0, 256).Select(i => + { + var k = (uint)i; + for (var j = 0; j < 8; j++) + k = (k & 1) != 0 ? + (k >> 1) ^ POLY : + k >> 1; + + return k; + }).ToArray(); + + public uint Checksum => ~_crc32; + + private uint _crc32 = 0xFFFFFFFF; + + /// + /// Initializes Crc32's state + /// + public void Init() + { + _crc32 = 0xFFFFFFFF; + } + + /// + /// Updates Crc32's state with new data + /// + /// Data to calculate the new CRC from + public void Update(byte[] data) + { + foreach (var b in data) + Update(b); + } + + public void Update(byte[] data, int offset, int length) + { + for (int i = offset, readIndex = offset + length; i < readIndex; i++) + Update(data[i]); + } + + public void Update(byte b) + { + _crc32 = CrcArray[(_crc32 ^ b) & 0xFF] ^ + ((_crc32 >> 8) & 0x00FFFFFF); + } + + public static uint Calculate(byte[] data, int offset, int length) + { + uint v = 0xFFFFFFFF; + for (int i = offset, readIndex = offset + length; i < readIndex; i++) + v = CrcArray[(v ^ data[i]) & 0xFF] ^ + ((v >> 8) & 0x00FFFFFF); + return ~v; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/Util/FullDeflateStreamReader.cs b/src/XIVLauncher2.Common/Patching/Util/FullDeflateStreamReader.cs new file mode 100644 index 0000000..ba3b4b9 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/Util/FullDeflateStreamReader.cs @@ -0,0 +1,22 @@ +using System; +using System.IO; +using System.IO.Compression; +// ReSharper disable InconsistentNaming + +namespace XIVLauncher2.Common.Patching.Util +{ + // works around https://docs.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/6.0/partial-byte-reads-in-streams + static class FullDeflateStreamReader + { + public static void FullRead(this DeflateStream stream, byte[] array, int offset, int count) + { + int totalRead = 0; + while (totalRead < count) + { + int bytesRead = stream.Read(array, offset + totalRead, count - totalRead); + if (bytesRead == 0) break; + totalRead += bytesRead; + } + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/Util/MultipartResponseHandler.cs b/src/XIVLauncher2.Common/Patching/Util/MultipartResponseHandler.cs new file mode 100644 index 0000000..42a70c2 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/Util/MultipartResponseHandler.cs @@ -0,0 +1,376 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace XIVLauncher2.Common.Patching.Util +{ + public class MultipartResponseHandler : IDisposable + { + private readonly HttpResponseMessage response; + private bool noMoreParts = false; + private Stream baseStream; + public string MultipartBoundary; + private string multipartEndBoundary; + private CircularMemoryStream multipartBufferStream; + private List multipartHeaderLines; + + public MultipartResponseHandler(HttpResponseMessage responseMessage) + { + this.response = responseMessage; + } + + public async Task NextPart(CancellationToken? cancellationToken = null) + { + if (this.noMoreParts) + return null; + + if (this.baseStream == null) + this.baseStream = new BufferedStream(await this.response.Content.ReadAsStreamAsync(), 16384); + + if (MultipartBoundary == null) + { + switch (this.response.StatusCode) + { + case System.Net.HttpStatusCode.OK: + { + this.noMoreParts = true; + var stream = new MultipartPartStream(this.response.Content.Headers.ContentLength.Value, 0, this.response.Content.Headers.ContentLength.Value); + stream.AppendBaseStream(new ReadLengthLimitingStream(this.baseStream, this.response.Content.Headers.ContentLength.Value)); + return stream; + } + + case System.Net.HttpStatusCode.PartialContent: + if (this.response.Content.Headers.ContentType.MediaType.ToLowerInvariant() != "multipart/byteranges") + { + this.noMoreParts = true; + var rangeHeader = this.response.Content.Headers.ContentRange; + var rangeLength = rangeHeader.To.Value + 1 - rangeHeader.From.Value; + var stream = new MultipartPartStream(rangeHeader.Length.Value, rangeHeader.From.Value, rangeLength); + stream.AppendBaseStream(new ReadLengthLimitingStream(this.baseStream, rangeLength)); + return stream; + } + else + { + MultipartBoundary = "--" + this.response.Content.Headers.ContentType.Parameters.Where(p => p.Name.ToLowerInvariant() == "boundary").First().Value; + this.multipartEndBoundary = MultipartBoundary + "--"; + this.multipartBufferStream = new(); + this.multipartHeaderLines = new(); + } + break; + + default: + this.response.EnsureSuccessStatusCode(); + throw new EndOfStreamException($"Unhandled success status code {this.response.StatusCode}"); + } + } + + while (true) + { + if (cancellationToken.HasValue) + cancellationToken.Value.ThrowIfCancellationRequested(); + + var eof = false; + using (var buffer = ReusableByteBufferManager.GetBuffer()) + { + int readSize; + if (cancellationToken == null) + readSize = await this.baseStream.ReadAsync(buffer.Buffer, 0, buffer.Buffer.Length); + else + readSize = await this.baseStream.ReadAsync(buffer.Buffer, 0, buffer.Buffer.Length, (CancellationToken)cancellationToken); + + if (readSize == 0) + eof = true; + else + this.multipartBufferStream.Feed(buffer.Buffer, 0, readSize); + } + + for (int i = 0; i < this.multipartBufferStream.Length - 1; ++i) + { + if (this.multipartBufferStream[i + 0] != '\r' || this.multipartBufferStream[i + 1] != '\n') + continue; + + var isEmptyLine = i == 0; + + if (isEmptyLine) + this.multipartBufferStream.Consume(null, 0, 2); + else + { + using var buffer = ReusableByteBufferManager.GetBuffer(); + if (i > buffer.Buffer.Length) + throw new IOException($"Multipart header line is too long ({i} bytes)"); + + this.multipartBufferStream.Consume(buffer.Buffer, 0, i + 2); + this.multipartHeaderLines.Add(Encoding.UTF8.GetString(buffer.Buffer, 0, i)); + } + i = -1; + + if (this.multipartHeaderLines.Count == 0) + continue; + if (this.multipartHeaderLines.Last() == this.multipartEndBoundary) + { + this.noMoreParts = true; + return null; + } + if (!isEmptyLine) + continue; + + ContentRangeHeaderValue rangeHeader = null; + foreach (var headerLine in this.multipartHeaderLines) + { + var kvs = headerLine.Split(new char[] { ':' }, 2); + if (kvs.Length != 2) + continue; + if (kvs[0].ToLowerInvariant() != "content-range") + continue; + if (ContentRangeHeaderValue.TryParse(kvs[1], out rangeHeader)) + break; + } + if (rangeHeader == null) + throw new IOException("Content-Range not found in multipart part"); + + this.multipartHeaderLines.Clear(); + var rangeFrom = rangeHeader.From.Value; + var rangeLength = rangeHeader.To.Value - rangeFrom + 1; + var stream = new MultipartPartStream(rangeHeader.Length.Value, rangeFrom, rangeLength); + stream.AppendBaseStream(new ConsumeLengthLimitingStream(this.multipartBufferStream, Math.Min(rangeLength, this.multipartBufferStream.Length))); + stream.AppendBaseStream(new ReadLengthLimitingStream(this.baseStream, stream.UnfulfilledBaseStreamLength)); + return stream; + } + + if (eof && !this.noMoreParts) + throw new EndOfStreamException("Reached premature EOF"); + } + } + + public void Dispose() + { + this.multipartBufferStream?.Dispose(); + this.baseStream?.Dispose(); + this.response?.Dispose(); + } + + private class ReadLengthLimitingStream : Stream + { + private readonly Stream baseStream; + private readonly long limitedLength; + private long limitedPointer = 0; + + public ReadLengthLimitingStream(Stream stream, long length) + { + this.baseStream = stream; + this.limitedLength = length; + } + + public override int Read(byte[] buffer, int offset, int count) + { + count = (int)Math.Min(count, this.limitedLength - this.limitedPointer); + if (count == 0) + return 0; + + var read = this.baseStream.Read(buffer, offset, count); + if (read == 0) + throw new EndOfStreamException("Premature end of stream detected"); + this.limitedPointer += read; + return read; + } + + public override long Length => this.limitedLength; + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Position { get => this.limitedPointer; set => throw new NotSupportedException(); } + + public override void Flush() => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + private class ConsumeLengthLimitingStream : Stream + { + private readonly CircularMemoryStream baseStream; + private readonly long limitedLength; + private long limitedPointer = 0; + + public ConsumeLengthLimitingStream(CircularMemoryStream stream, long length) + { + this.baseStream = stream; + this.limitedLength = length; + } + + public override int Read(byte[] buffer, int offset, int count) + { + count = (int)Math.Min(count, this.limitedLength - this.limitedPointer); + if (count == 0) + return 0; + + var read = this.baseStream.Consume(buffer, offset, count); + if (read == 0) + throw new EndOfStreamException("Premature end of stream detected"); + this.limitedPointer += read; + return read; + } + + public override long Length => this.limitedLength; + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Position { get => this.limitedPointer; set => throw new NotSupportedException(); } + + public override void Flush() => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + public class MultipartPartStream : Stream + { + private readonly CircularMemoryStream loopStream = new(16384, CircularMemoryStream.FeedOverflowMode.DiscardOldest); + private readonly List baseStreams = new(); + private int baseStreamIndex = 0; + public readonly long OriginTotalLength; + public readonly long OriginOffset; + public readonly long OriginLength; + public long OriginEnd => OriginOffset + OriginLength; + private long positionInternal; + + internal MultipartPartStream(long originTotalLength, long originOffset, long originLength) + { + OriginTotalLength = originTotalLength; + OriginOffset = originOffset; + OriginLength = originLength; + this.positionInternal = originOffset; + } + + internal void AppendBaseStream(Stream stream) + { + if (stream.Length == 0) + return; + if (UnfulfilledBaseStreamLength < stream.Length) + throw new ArgumentException("Total length of given streams exceed OriginTotalLength."); + this.baseStreams.Add(stream); + } + + internal long UnfulfilledBaseStreamLength => OriginLength - this.baseStreams.Select(x => x.Length).Sum(); + + public void CaptureBackwards(long captureCapacity) + { + this.loopStream.Reserve(captureCapacity); + } + + public override int Read(byte[] buffer, int offset, int count) + { + int totalRead = 0; + while (count > 0 && this.loopStream.Position < this.loopStream.Length) + { + var read1 = (int)Math.Min(count, this.loopStream.Length - this.loopStream.Position); + var read2 = this.loopStream.Read(buffer, offset, read1); + if (read2 == 0) + throw new EndOfStreamException("MultipartPartStream.Read:1"); + + totalRead += read2; + this.positionInternal += read2; + count -= read2; + offset += read2; + } + + while (count > 0 && this.baseStreamIndex < this.baseStreams.Count) + { + var stream = this.baseStreams[this.baseStreamIndex]; + var read1 = (int)Math.Min(count, stream.Length - stream.Position); + var read2 = stream.Read(buffer, offset, read1); + if (read2 == 0) + throw new EndOfStreamException("MultipartPartStream.Read:2"); + + this.loopStream.Feed(buffer, offset, read2); + this.loopStream.Position = this.loopStream.Length; + + totalRead += read2; + this.positionInternal += read2; + count -= read2; + offset += read2; + + if (stream.Position == stream.Length) + this.baseStreamIndex++; + } + + return totalRead; + } + + public override long Seek(long offset, SeekOrigin origin) + { + switch (origin) + { + case SeekOrigin.Begin: + offset -= this.positionInternal; + break; + case SeekOrigin.End: + offset = OriginTotalLength - offset - this.positionInternal; + break; + } + + var finalPosition = this.positionInternal + offset; + + if (finalPosition > OriginOffset + OriginLength) + throw new ArgumentException("Tried to seek after the end of the segment."); + else if (finalPosition < OriginOffset) + throw new ArgumentException("Tried to seek behind the beginning of the segment."); + + var backwards = this.loopStream.Length - this.loopStream.Position; + var backwardAdjustment = Math.Min(backwards, offset); + this.loopStream.Position += backwardAdjustment; // This will throw if there are not enough old data available + offset -= backwardAdjustment; + this.positionInternal += backwardAdjustment; + + if (offset > 0) + { + using var buf = ReusableByteBufferManager.GetBuffer(); + for (var i = 0; i < offset; i += buf.Buffer.Length) + if (0 == Read(buf.Buffer, 0, (int)Math.Min(offset - i, buf.Buffer.Length))) + throw new EndOfStreamException("MultipartPartStream.Read:3"); + } + + if (this.positionInternal != finalPosition) + throw new IOException("Failed to seek properly."); + + return this.positionInternal; + } + + public override bool CanRead => true; + + public override bool CanSeek => true; + + public override bool CanWrite => false; + + public override long Length => OriginTotalLength; + + public override long Position { get => this.positionInternal; set => Seek(value, SeekOrigin.Begin); } + + public override void Flush() => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/Util/ReusableByteBufferManager.cs b/src/XIVLauncher2.Common/Patching/Util/ReusableByteBufferManager.cs new file mode 100644 index 0000000..7ac0a4d --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/Util/ReusableByteBufferManager.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Linq; + +namespace XIVLauncher2.Common.Patching.Util +{ + public class ReusableByteBufferManager + { + private static readonly int[] ArraySizes = new int[] { 1 << 14, 1 << 16, 1 << 18, 1 << 20, 1 << 22 }; + private static readonly ReusableByteBufferManager[] Instances = ArraySizes.Select(x => new ReusableByteBufferManager(x, 2 * Environment.ProcessorCount)).ToArray(); + + public class Allocation : IDisposable + { + public readonly ReusableByteBufferManager BufferManager; + public readonly byte[] Buffer; + public readonly MemoryStream Stream; + public readonly BinaryWriter Writer; + + internal Allocation(ReusableByteBufferManager b, long size) + { + BufferManager = b; + Buffer = new byte[size]; + Stream = new MemoryStream(Buffer); + Writer = new BinaryWriter(Stream); + } + + public void ResetState() + { + Stream.SetLength(0); + Stream.Seek(0, SeekOrigin.Begin); + } + + public void Clear() => Array.Clear(Buffer, 0, Buffer.Length); + + public void Dispose() => BufferManager?.Return(this); + } + + private readonly int arraySize; + private readonly Allocation[] buffers; + + public ReusableByteBufferManager(int arraySize, int maxBuffers) + { + this.arraySize = arraySize; + this.buffers = new Allocation[maxBuffers]; + } + + public Allocation Allocate(bool clear = false) + { + Allocation res = null; + + for (int i = 0; i < this.buffers.Length; i++) + { + if (this.buffers[i] == null) + continue; + + lock (this.buffers.SyncRoot) + { + if (this.buffers[i] == null) + continue; + + res = this.buffers[i]; + this.buffers[i] = null; + break; + } + } + if (res == null) + res = new Allocation(this, this.arraySize); + else if (clear) + res.Clear(); + res.ResetState(); + return res; + } + + internal void Return(Allocation buf) + { + for (int i = 0; i < this.buffers.Length; i++) + { + if (this.buffers[i] != null) + continue; + + lock (this.buffers.SyncRoot) + { + if (this.buffers[i] != null) + continue; + + this.buffers[i] = buf; + return; + } + } + } + + public static Allocation GetBuffer(bool clear = false) + { + return Instances[0].Allocate(clear); + } + + public static Allocation GetBuffer(long minSize, bool clear = false) + { + for (int i = 0; i < ArraySizes.Length; i++) + if (ArraySizes[i] >= minSize) + return Instances[i].Allocate(clear); + + return new Allocation(null, minSize); + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/AddDirectoryChunk.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/AddDirectoryChunk.cs new file mode 100644 index 0000000..2f722cb --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/AddDirectoryChunk.cs @@ -0,0 +1,36 @@ +using System.IO; +using XIVLauncher2.Common.Patching.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk +{ + public class AddDirectoryChunk : ZiPatchChunk + { + public new static string Type = "ADIR"; + + public string DirName { get; protected set; } + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + var dirNameLen = this.Reader.ReadUInt32BE(); + + DirName = this.Reader.ReadFixedLengthString(dirNameLen); + + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + + public AddDirectoryChunk(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + public override void ApplyChunk(ZiPatchConfig config) + { + Directory.CreateDirectory(config.GamePath + DirName); + } + + public override string ToString() + { + return $"{Type}:{DirName}"; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/ApplyFreeSpaceChunk.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/ApplyFreeSpaceChunk.cs new file mode 100644 index 0000000..b715e47 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/ApplyFreeSpaceChunk.cs @@ -0,0 +1,31 @@ +using XIVLauncher2.Common.Patching.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk +{ + public class ApplyFreeSpaceChunk : ZiPatchChunk + { + // This is a NOP on recent patcher versions, so I don't think we'll be seeing it. + public new static string Type = "APFS"; + + // TODO: No samples of this were found, so these fields are theoretical + public long UnknownFieldA { get; protected set; } + public long UnknownFieldB { get; protected set; } + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + UnknownFieldA = this.Reader.ReadInt64BE(); + UnknownFieldB = this.Reader.ReadInt64BE(); + + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + public ApplyFreeSpaceChunk(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + public override string ToString() + { + return $"{Type}:{UnknownFieldA}:{UnknownFieldB}"; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/ApplyOptionChunk.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/ApplyOptionChunk.cs new file mode 100644 index 0000000..36a43ca --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/ApplyOptionChunk.cs @@ -0,0 +1,61 @@ +using XIVLauncher2.Common.Patching.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk +{ + public class ApplyOptionChunk : ZiPatchChunk + { + public new static string Type = "APLY"; + + public enum ApplyOptionKind : uint + { + IgnoreMissing = 1, + IgnoreOldMismatch = 2 + } + + // These are both false on all files seen + public ApplyOptionKind OptionKind { get; protected set; } + + public bool OptionValue { get; protected set; } + + public ApplyOptionChunk(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + OptionKind = (ApplyOptionKind)this.Reader.ReadUInt32BE(); + + // Discarded padding, always 0x0000_0004 as far as observed + this.Reader.ReadBytes(4); + + var value = this.Reader.ReadUInt32BE() != 0; + + if (OptionKind == ApplyOptionKind.IgnoreMissing || + OptionKind == ApplyOptionKind.IgnoreOldMismatch) + OptionValue = value; + else + OptionValue = false; // defaults to false if OptionKind isn't valid + + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + public override void ApplyChunk(ZiPatchConfig config) + { + switch (OptionKind) + { + case ApplyOptionKind.IgnoreMissing: + config.IgnoreMissing = OptionValue; + break; + + case ApplyOptionKind.IgnoreOldMismatch: + config.IgnoreOldMismatch = OptionValue; + break; + } + } + + public override string ToString() + { + return $"{Type}:{OptionKind}:{OptionValue}"; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/DeleteDirectoryChunk.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/DeleteDirectoryChunk.cs new file mode 100644 index 0000000..76528a2 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/DeleteDirectoryChunk.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; +using Serilog; +using XIVLauncher2.Common.Patching.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk +{ + public class DeleteDirectoryChunk : ZiPatchChunk + { + public new static string Type = "DELD"; + + public string DirName { get; protected set; } + + public DeleteDirectoryChunk(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + var dirNameLen = this.Reader.ReadUInt32BE(); + + DirName = this.Reader.ReadFixedLengthString(dirNameLen); + + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + public override void ApplyChunk(ZiPatchConfig config) + { + try + { + Directory.Delete(config.GamePath + DirName); + } + catch (Exception e) + { + Log.Debug(e, "Ran into {This}, failed at deleting the dir", this); + throw; + } + } + + public override string ToString() + { + return $"{Type}:{DirName}"; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/EndOfFileChunk.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/EndOfFileChunk.cs new file mode 100644 index 0000000..631f036 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/EndOfFileChunk.cs @@ -0,0 +1,23 @@ +using XIVLauncher2.Common.Patching.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk +{ + public class EndOfFileChunk : ZiPatchChunk + { + public new static string Type = "EOF_"; + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + public EndOfFileChunk(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + public override string ToString() + { + return Type; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/FileHeaderChunk.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/FileHeaderChunk.cs new file mode 100644 index 0000000..30b8205 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/FileHeaderChunk.cs @@ -0,0 +1,62 @@ +using XIVLauncher2.Common.Patching.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk +{ + public class FileHeaderChunk : ZiPatchChunk + { + public new static string Type = "FHDR"; + + // V1?/2 + public byte Version { get; protected set; } + public string PatchType { get; protected set; } + public uint EntryFiles { get; protected set; } + + // V3 + public uint AddDirectories { get; protected set; } + public uint DeleteDirectories { get; protected set; } + public long DeleteDataSize { get; protected set; } // Split in 2 DWORD; Low, High + public uint MinorVersion { get; protected set; } + public uint RepositoryName { get; protected set; } + public uint Commands { get; protected set; } + public uint SqpkAddCommands { get; protected set; } + public uint SqpkDeleteCommands { get; protected set; } + public uint SqpkExpandCommands { get; protected set; } + public uint SqpkHeaderCommands { get; protected set; } + public uint SqpkFileCommands { get; protected set; } + + public FileHeaderChunk(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + Version = (byte)(this.Reader.ReadUInt32() >> 16); + PatchType = this.Reader.ReadFixedLengthString(4u); + EntryFiles = this.Reader.ReadUInt32BE(); + + if (Version == 3) + { + AddDirectories = this.Reader.ReadUInt32BE(); + DeleteDirectories = this.Reader.ReadUInt32BE(); + DeleteDataSize = this.Reader.ReadUInt32BE() | ((long)this.Reader.ReadUInt32BE() << 32); + MinorVersion = this.Reader.ReadUInt32BE(); + RepositoryName = this.Reader.ReadUInt32BE(); + Commands = this.Reader.ReadUInt32BE(); + SqpkAddCommands = this.Reader.ReadUInt32BE(); + SqpkDeleteCommands = this.Reader.ReadUInt32BE(); + SqpkExpandCommands = this.Reader.ReadUInt32BE(); + SqpkHeaderCommands = this.Reader.ReadUInt32BE(); + SqpkFileCommands = this.Reader.ReadUInt32BE(); + } + + // 0xB8 of unknown data for V3, 0x08 of 0x00 for V2 + // ... Probably irrelevant. + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + public override string ToString() + { + return $"{Type}:V{Version}:{RepositoryName}"; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkChunk.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkChunk.cs new file mode 100644 index 0000000..16f1684 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkChunk.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.IO; +using XIVLauncher2.Common.Patching.Util; +using XIVLauncher2.Common.Patching.ZiPatch.Chunk.SqpkCommand; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk +{ + public abstract class SqpkChunk : ZiPatchChunk + { + public new static string Type = "SQPK"; + public static string Command { get; protected set; } + + + private static readonly Dictionary> CommandTypes = + new Dictionary> { + { SqpkAddData.Command, (reader, offset, size) => new SqpkAddData(reader, offset, size) }, + { SqpkDeleteData.Command, (reader, offset, size) => new SqpkDeleteData(reader, offset, size) }, + { SqpkHeader.Command, (reader, offset, size) => new SqpkHeader(reader, offset, size) }, + { SqpkTargetInfo.Command, (reader, offset, size) => new SqpkTargetInfo(reader, offset, size) }, + { SqpkExpandData.Command, (reader, offset, size) => new SqpkExpandData(reader, offset, size) }, + { SqpkIndex.Command, (reader, offset, size) => new SqpkIndex(reader, offset, size) }, + { SqpkFile.Command, (reader, offset, size) => new SqpkFile(reader, offset, size) }, + { SqpkPatchInfo.Command, (reader, offset, size) => new SqpkPatchInfo(reader, offset, size) } + }; + + public static ZiPatchChunk GetCommand(ChecksumBinaryReader reader, int offset, int size) + { + try + { + // Have not seen this differ from size + var innerSize = reader.ReadInt32BE(); + if (size != innerSize) + throw new ZiPatchException(); + + var command = reader.ReadFixedLengthString(1u); + if (!CommandTypes.TryGetValue(command, out var constructor)) + throw new ZiPatchException(); + + var chunk = constructor(reader, offset, innerSize - 5); + + return chunk; + } + catch (EndOfStreamException e) + { + throw new ZiPatchException("Could not get command", e); + } + } + + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + protected SqpkChunk(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) + { } + + public override string ToString() + { + return Type; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkAddData.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkAddData.cs new file mode 100644 index 0000000..d277ca7 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkAddData.cs @@ -0,0 +1,58 @@ +using System.IO; +using XIVLauncher2.Common.Patching.Util; +using XIVLauncher2.Common.Patching.ZiPatch.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk.SqpkCommand +{ + class SqpkAddData : SqpkChunk + { + public new static string Command = "A"; + + + public SqpackDatFile TargetFile { get; protected set; } + public int BlockOffset { get; protected set; } + public int BlockNumber { get; protected set; } + public int BlockDeleteNumber { get; protected set; } + + public byte[] BlockData { get; protected set; } + public long BlockDataSourceOffset { get; protected set; } + + + public SqpkAddData(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + this.Reader.ReadBytes(3); // Alignment + + TargetFile = new SqpackDatFile(this.Reader); + + BlockOffset = this.Reader.ReadInt32BE() << 7; + BlockNumber = this.Reader.ReadInt32BE() << 7; + BlockDeleteNumber = this.Reader.ReadInt32BE() << 7; + + BlockDataSourceOffset = Offset + this.Reader.BaseStream.Position; + BlockData = this.Reader.ReadBytes((int)BlockNumber); + + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + public override void ApplyChunk(ZiPatchConfig config) + { + TargetFile.ResolvePath(config.Platform); + + var file = config.Store == null ? + TargetFile.OpenStream(config.GamePath, FileMode.OpenOrCreate) : + TargetFile.OpenStream(config.Store, config.GamePath, FileMode.OpenOrCreate); + + file.WriteFromOffset(BlockData, BlockOffset); + file.Wipe(BlockDeleteNumber); + } + + public override string ToString() + { + return $"{Type}:{Command}:{TargetFile}:{BlockOffset}:{BlockNumber}:{BlockDeleteNumber}"; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkDeleteData.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkDeleteData.cs new file mode 100644 index 0000000..c734b4a --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkDeleteData.cs @@ -0,0 +1,51 @@ +using System.IO; +using XIVLauncher2.Common.Patching.Util; +using XIVLauncher2.Common.Patching.ZiPatch.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk.SqpkCommand +{ + class SqpkDeleteData : SqpkChunk + { + public new static string Command = "D"; + + + public SqpackDatFile TargetFile { get; protected set; } + public int BlockOffset { get; protected set; } + public int BlockNumber { get; protected set; } + + + public SqpkDeleteData(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + this.Reader.ReadBytes(3); // Alignment + + TargetFile = new SqpackDatFile(this.Reader); + + BlockOffset = this.Reader.ReadInt32BE() << 7; + BlockNumber = this.Reader.ReadInt32BE(); + + this.Reader.ReadUInt32(); // Reserved + + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + public override void ApplyChunk(ZiPatchConfig config) + { + TargetFile.ResolvePath(config.Platform); + + var file = config.Store == null ? + TargetFile.OpenStream(config.GamePath, FileMode.OpenOrCreate) : + TargetFile.OpenStream(config.Store, config.GamePath, FileMode.OpenOrCreate); + + SqpackDatFile.WriteEmptyFileBlockAt(file, BlockOffset, BlockNumber); + } + + public override string ToString() + { + return $"{Type}:{Command}:{TargetFile}:{BlockOffset}:{BlockNumber}"; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkExpandData.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkExpandData.cs new file mode 100644 index 0000000..4c1490b --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkExpandData.cs @@ -0,0 +1,51 @@ +using System.IO; +using XIVLauncher2.Common.Patching.Util; +using XIVLauncher2.Common.Patching.ZiPatch.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk.SqpkCommand +{ + class SqpkExpandData : SqpkChunk + { + public new static string Command = "E"; + + + public SqpackDatFile TargetFile { get; protected set; } + public int BlockOffset { get; protected set; } + public int BlockNumber { get; protected set; } + + + public SqpkExpandData(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + this.Reader.ReadBytes(3); + + TargetFile = new SqpackDatFile(this.Reader); + + BlockOffset = this.Reader.ReadInt32BE() << 7; + BlockNumber = this.Reader.ReadInt32BE(); + + this.Reader.ReadUInt32(); // Reserved + + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + public override void ApplyChunk(ZiPatchConfig config) + { + TargetFile.ResolvePath(config.Platform); + + var file = config.Store == null ? + TargetFile.OpenStream(config.GamePath, FileMode.OpenOrCreate) : + TargetFile.OpenStream(config.Store, config.GamePath, FileMode.OpenOrCreate); + + SqpackDatFile.WriteEmptyFileBlockAt(file, BlockOffset, BlockNumber); + } + + public override string ToString() + { + return $"{Type}:{Command}:{BlockOffset}:{BlockNumber}"; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkFile.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkFile.cs new file mode 100644 index 0000000..28ff288 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkFile.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using XIVLauncher2.Common.Patching.Util; +using XIVLauncher2.Common.Patching.ZiPatch.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk.SqpkCommand +{ + internal class SqpkFile : SqpkChunk + { + public new static string Command = "F"; + + public enum OperationKind : byte + { + AddFile = (byte)'A', + RemoveAll = (byte)'R', + + // I've seen no cases in the wild of these two + DeleteFile = (byte)'D', + MakeDirTree = (byte)'M' + } + + public OperationKind Operation { get; protected set; } + public long FileOffset { get; protected set; } + public ulong FileSize { get; protected set; } + public ushort ExpansionId { get; protected set; } + public SqexFile TargetFile { get; protected set; } + + public List CompressedDataSourceOffsets { get; protected set; } + public List CompressedData { get; protected set; } + + public SqpkFile(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + Operation = (OperationKind)this.Reader.ReadByte(); + this.Reader.ReadBytes(2); // Alignment + + FileOffset = this.Reader.ReadInt64BE(); + FileSize = this.Reader.ReadUInt64BE(); + + var pathLen = this.Reader.ReadUInt32BE(); + + ExpansionId = this.Reader.ReadUInt16BE(); + this.Reader.ReadBytes(2); + + TargetFile = new SqexFile(this.Reader.ReadFixedLengthString(pathLen)); + + if (Operation == OperationKind.AddFile) + { + CompressedDataSourceOffsets = new(); + CompressedData = new List(); + + while (Size - this.Reader.BaseStream.Position + start > 0) + { + CompressedDataSourceOffsets.Add(Offset + this.Reader.BaseStream.Position); + CompressedData.Add(new SqpkCompressedBlock(this.Reader)); + CompressedDataSourceOffsets[CompressedDataSourceOffsets.Count - 1] += CompressedData[CompressedData.Count - 1].HeaderSize; + } + } + + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + private static bool RemoveAllFilter(string filePath) => + !new[] { ".var", "00000.bk2", "00001.bk2", "00002.bk2", "00003.bk2" }.Any(filePath.EndsWith); + + public override void ApplyChunk(ZiPatchConfig config) + { + switch (Operation) + { + // Default behaviour falls through to AddFile, though this shouldn't happen + case OperationKind.AddFile: + default: + // TODO: Check this. I *think* boot usually creates all the folders like sqpack, movie, etc., so this might be kind of a hack + TargetFile.CreateDirectoryTree(config.GamePath); + + var fileStream = config.Store == null ? TargetFile.OpenStream(config.GamePath, FileMode.OpenOrCreate) : TargetFile.OpenStream(config.Store, config.GamePath, FileMode.OpenOrCreate); + + if (FileOffset == 0) + fileStream.SetLength(0); + + fileStream.Seek(FileOffset, SeekOrigin.Begin); + foreach (var block in CompressedData) + block.DecompressInto(fileStream); + + break; + + case OperationKind.RemoveAll: + foreach (var file in SqexFile.GetAllExpansionFiles(config.GamePath, ExpansionId).Where(RemoveAllFilter)) + File.Delete(file); + break; + + case OperationKind.DeleteFile: + File.Delete(config.GamePath + "/" + TargetFile.RelativePath); + break; + + case OperationKind.MakeDirTree: + Directory.CreateDirectory(config.GamePath + "/" + TargetFile.RelativePath); + break; + } + } + + public override string ToString() + { + return $"{Type}:{Command}:{Operation}:{FileOffset}:{FileSize}:{ExpansionId}:{TargetFile}"; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkHeader.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkHeader.cs new file mode 100644 index 0000000..5bf1103 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkHeader.cs @@ -0,0 +1,69 @@ +using System.IO; +using XIVLauncher2.Common.Patching.Util; +using XIVLauncher2.Common.Patching.ZiPatch.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk.SqpkCommand +{ + class SqpkHeader : SqpkChunk + { + public new static string Command = "H"; + + public enum TargetFileKind : byte + { + Dat = (byte)'D', + Index = (byte)'I' + } + public enum TargetHeaderKind : byte + { + Version = (byte)'V', + Index = (byte)'I', + Data = (byte)'D' + } + + public const int HEADER_SIZE = 1024; + + public TargetFileKind FileKind { get; protected set; } + public TargetHeaderKind HeaderKind { get; protected set; } + public SqpackFile TargetFile { get; protected set; } + + public byte[] HeaderData { get; protected set; } + public long HeaderDataSourceOffset { get; protected set; } + + public SqpkHeader(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + FileKind = (TargetFileKind)this.Reader.ReadByte(); + HeaderKind = (TargetHeaderKind)this.Reader.ReadByte(); + this.Reader.ReadByte(); // Alignment + + if (FileKind == TargetFileKind.Dat) + TargetFile = new SqpackDatFile(this.Reader); + else + TargetFile = new SqpackIndexFile(this.Reader); + + HeaderDataSourceOffset = Offset + this.Reader.BaseStream.Position; + HeaderData = this.Reader.ReadBytes(HEADER_SIZE); + + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + public override void ApplyChunk(ZiPatchConfig config) + { + TargetFile.ResolvePath(config.Platform); + + var file = config.Store == null ? + TargetFile.OpenStream(config.GamePath, FileMode.OpenOrCreate) : + TargetFile.OpenStream(config.Store, config.GamePath, FileMode.OpenOrCreate); + + file.WriteFromOffset(HeaderData, HeaderKind == TargetHeaderKind.Version ? 0 : HEADER_SIZE); + } + + public override string ToString() + { + return $"{Type}:{Command}:{FileKind}:{HeaderKind}:{TargetFile}"; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkIndex.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkIndex.cs new file mode 100644 index 0000000..87be07e --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkIndex.cs @@ -0,0 +1,53 @@ +using XIVLauncher2.Common.Patching.Util; +using XIVLauncher2.Common.Patching.ZiPatch.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk.SqpkCommand +{ + class SqpkIndex : SqpkChunk + { + // This is a NOP on recent patcher versions. + public new static string Command = "I"; + + public enum IndexCommandKind : byte + { + Add = (byte)'A', + Delete = (byte)'D' + } + + public IndexCommandKind IndexCommand { get; protected set; } + public bool IsSynonym { get; protected set; } + public SqpackIndexFile TargetFile { get; protected set; } + public ulong FileHash { get; protected set; } + public uint BlockOffset { get; protected set; } + + // TODO: Figure out what this is used for + public uint BlockNumber { get; protected set; } + + + + public SqpkIndex(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + IndexCommand = (IndexCommandKind)this.Reader.ReadByte(); + IsSynonym = this.Reader.ReadBoolean(); + this.Reader.ReadByte(); // Alignment + + TargetFile = new SqpackIndexFile(this.Reader); + + FileHash = this.Reader.ReadUInt64BE(); + + BlockOffset = this.Reader.ReadUInt32BE(); + BlockNumber = this.Reader.ReadUInt32BE(); + + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + public override string ToString() + { + return $"{Type}:{Command}:{IndexCommand}:{IsSynonym}:{TargetFile}:{FileHash:X8}:{BlockOffset}:{BlockNumber}"; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkPatchInfo.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkPatchInfo.cs new file mode 100644 index 0000000..e43823f --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkPatchInfo.cs @@ -0,0 +1,35 @@ +using XIVLauncher2.Common.Patching.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk.SqpkCommand +{ + internal class SqpkPatchInfo : SqpkChunk + { + // This is a NOP on recent patcher versions + public new static string Command = "X"; + + // Don't know what this stuff is for + public byte Status { get; protected set; } + public byte Version { get; protected set; } + public ulong InstallSize { get; protected set; } + + public SqpkPatchInfo(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + Status = this.Reader.ReadByte(); + Version = this.Reader.ReadByte(); + this.Reader.ReadByte(); // Alignment + + InstallSize = this.Reader.ReadUInt64BE(); + + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + public override string ToString() + { + return $"{Type}:{Command}:{Status}:{Version}:{InstallSize}"; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkTargetInfo.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkTargetInfo.cs new file mode 100644 index 0000000..4fe99c6 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/SqpkCommand/SqpkTargetInfo.cs @@ -0,0 +1,55 @@ +using XIVLauncher2.Common.Patching.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk.SqpkCommand +{ + internal class SqpkTargetInfo : SqpkChunk + { + // Only Platform is used on recent patcher versions + public new static string Command = "T"; + + // US/EU/JP are Global + // ZH seems to also be Global + // KR is unknown + public enum RegionId : short + { + Global = -1 + } + + public ZiPatchConfig.PlatformId Platform { get; protected set; } + public RegionId Region { get; protected set; } + public bool IsDebug { get; protected set; } + public ushort Version { get; protected set; } + public ulong DeletedDataSize { get; protected set; } + public ulong SeekCount { get; protected set; } + + public SqpkTargetInfo(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + // Reserved + this.Reader.ReadBytes(3); + + Platform = (ZiPatchConfig.PlatformId)this.Reader.ReadUInt16BE(); + Region = (RegionId)this.Reader.ReadInt16BE(); + IsDebug = this.Reader.ReadInt16BE() != 0; + Version = this.Reader.ReadUInt16BE(); + DeletedDataSize = this.Reader.ReadUInt64(); + SeekCount = this.Reader.ReadUInt64(); + + // Empty 32 + 64 bytes + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + public override void ApplyChunk(ZiPatchConfig config) + { + config.Platform = Platform; + } + + public override string ToString() + { + return $"{Type}:{Command}:{Platform}:{Region}:{IsDebug}:{Version}:{DeletedDataSize}:{SeekCount}"; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/XXXXChunk.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/XXXXChunk.cs new file mode 100644 index 0000000..f80e7bc --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/XXXXChunk.cs @@ -0,0 +1,25 @@ +using XIVLauncher2.Common.Patching.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk +{ + // ReSharper disable once InconsistentNaming + public class XXXXChunk : ZiPatchChunk + { + // TODO: This... Never happens. + public new static string Type = "XXXX"; + + protected override void ReadChunk() + { + var start = this.Reader.BaseStream.Position; + + this.Reader.ReadBytes(Size - (int)(this.Reader.BaseStream.Position - start)); + } + + public XXXXChunk(ChecksumBinaryReader reader, int offset, int size) : base(reader, offset, size) {} + + public override string ToString() + { + return Type; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/ZiPatchChunk.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/ZiPatchChunk.cs new file mode 100644 index 0000000..f70dc1c --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Chunk/ZiPatchChunk.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Threading; +using XIVLauncher2.Common.Patching.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Chunk +{ + public abstract class ZiPatchChunk + { + public static string Type { get; protected set; } + // Hack: C# doesn't let you get static fields from instances. + public virtual string ChunkType => (string) GetType() + .GetField("Type", BindingFlags.Static | BindingFlags.FlattenHierarchy | BindingFlags.Public) + ?.GetValue(null); + + public int Offset { get; protected set; } + public int Size { get; protected set; } + public uint Checksum { get; protected set; } + public uint CalculatedChecksum { get; protected set; } + + protected readonly ChecksumBinaryReader Reader; + + private static readonly AsyncLocal localMemoryStream = new AsyncLocal(); + + + // Only FileHeader, ApplyOption, Sqpk, and EOF have been observed in XIVARR+ patches + // AddDirectory and DeleteDirectory can theoretically happen, so they're implemented + // ApplyFreeSpace doesn't seem to show up anymore, and EntryFile will just error out + private static readonly Dictionary> ChunkTypes = + new Dictionary> { + { FileHeaderChunk.Type, (reader, offset, size) => new FileHeaderChunk(reader, offset, size) }, + { ApplyOptionChunk.Type, (reader, offset, size) => new ApplyOptionChunk(reader, offset, size) }, + { ApplyFreeSpaceChunk.Type, (reader, offset, size) => new ApplyFreeSpaceChunk(reader, offset, size) }, + { AddDirectoryChunk.Type, (reader, offset, size) => new AddDirectoryChunk(reader, offset, size) }, + { DeleteDirectoryChunk.Type, (reader, offset, size) => new DeleteDirectoryChunk(reader, offset, size) }, + { SqpkChunk.Type, SqpkChunk.GetCommand }, + { EndOfFileChunk.Type, (reader, offset, size) => new EndOfFileChunk(reader, offset, size) }, + { XXXXChunk.Type, (reader, offset, size) => new XXXXChunk(reader, offset, size) } + }; + + + public static ZiPatchChunk GetChunk(Stream stream) + { + localMemoryStream.Value = localMemoryStream.Value ?? new MemoryStream(); + + var memoryStream = localMemoryStream.Value; + try + { + var reader = new BinaryReader(stream); + var size = reader.ReadInt32BE(); + var baseOffset = (int)stream.Position; + + // size of chunk + header + checksum + var readSize = size + 4 + 4; + + // Enlarge MemoryStream if necessary, or set length at capacity + var maxLen = Math.Max(readSize, memoryStream.Capacity); + if (memoryStream.Length < maxLen) + memoryStream.SetLength(maxLen); + + // Read into MemoryStream's inner buffer + reader.BaseStream.Read(memoryStream.GetBuffer(), 0, readSize); + + var binaryReader = new ChecksumBinaryReader(memoryStream); + binaryReader.InitCrc32(); + + var type = binaryReader.ReadFixedLengthString(4u); + if (!ChunkTypes.TryGetValue(type, out var constructor)) + throw new ZiPatchException(); + + + var chunk = constructor(binaryReader, baseOffset, size); + + chunk.ReadChunk(); + chunk.ReadChecksum(); + return chunk; + } + catch (EndOfStreamException e) + { + throw new ZiPatchException("Could not get chunk", e); + } + finally + { + memoryStream.Position = 0; + } + } + + protected ZiPatchChunk(ChecksumBinaryReader reader, int offset, int size) + { + this.Reader = reader; + + Offset = offset; + Size = size; + } + + protected virtual void ReadChunk() + { + this.Reader.ReadBytes(Size); + } + + public virtual void ApplyChunk(ZiPatchConfig config) {} + + protected void ReadChecksum() + { + CalculatedChecksum = this.Reader.GetCrc32(); + Checksum = this.Reader.ReadUInt32BE(); + } + + public bool IsChecksumValid => CalculatedChecksum == Checksum; + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqexFile.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqexFile.cs new file mode 100644 index 0000000..75da350 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqexFile.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Util +{ + public class SqexFile + { + public string RelativePath { get; set; } + + protected SqexFile() {} + + public SqexFile(string relativePath) + { + RelativePath = relativePath; + } + + public SqexFileStream? OpenStream(string basePath, FileMode mode, int tries = 5, int sleeptime = 1) => + SqexFileStream.WaitForStream($@"{basePath}/{RelativePath}", mode, tries, sleeptime); + + public SqexFileStream OpenStream(SqexFileStreamStore store, string basePath, FileMode mode, + int tries = 5, int sleeptime = 1) => + store.GetStream($@"{basePath}/{RelativePath}", mode, tries, sleeptime); + + public void CreateDirectoryTree(string basePath) + { + var dirName = Path.GetDirectoryName($@"{basePath}/{RelativePath}"); + if (dirName != null) + Directory.CreateDirectory(dirName); + } + + public override string ToString() => RelativePath; + + public static string GetExpansionFolder(byte expansionId) => + expansionId == 0 ? "ffxiv" : $"ex{expansionId}"; + + public static IEnumerable GetAllExpansionFiles(string fullPath, ushort expansionId) + { + var xpacPath = GetExpansionFolder((byte)expansionId); + + var sqpack = $@"{fullPath}\sqpack\{xpacPath}"; + var movie = $@"{fullPath}\movie\{xpacPath}"; + + var files = Enumerable.Empty(); + + if (Directory.Exists(sqpack)) + files = files.Concat(Directory.GetFiles(sqpack)); + + if (Directory.Exists(movie)) + files = files.Concat(Directory.GetFiles(movie)); + + return files; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqexFileStream.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqexFileStream.cs new file mode 100644 index 0000000..01b2aa1 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqexFileStream.cs @@ -0,0 +1,55 @@ +using System; +using System.IO; +using System.Threading; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Util +{ + public class SqexFileStream : FileStream + { + private static readonly byte[] WipeBuffer = new byte[1 << 16]; + + public SqexFileStream(string path, FileMode mode) : base(path, mode, FileAccess.ReadWrite, FileShare.Read, 1 << 16) + {} + + public static SqexFileStream? WaitForStream(string path, FileMode mode, int tries = 5, int sleeptime = 1) + { + do + { + try + { + return new SqexFileStream(path, mode); + } + catch (IOException) + { + if (tries == 0) + throw; + + Thread.Sleep(sleeptime * 1000); + } + } while (0 < --tries); + + return null; + } + + public void WriteFromOffset(byte[] data, int offset) + { + Seek(offset, SeekOrigin.Begin); + Write(data, 0, data.Length); + } + + public void Wipe(int length) + { + for (int numBytes; length > 0; length -= numBytes) + { + numBytes = Math.Min(WipeBuffer.Length, length); + Write(WipeBuffer, 0, numBytes); + } + } + + public void WipeFromOffset(int length, int offset) + { + Seek(offset, SeekOrigin.Begin); + Wipe(length); + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqexFileStreamStore.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqexFileStreamStore.cs new file mode 100644 index 0000000..621738d --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqexFileStreamStore.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Util +{ + public class SqexFileStreamStore : IDisposable + { + private readonly Dictionary _streams = new Dictionary(); + + public SqexFileStream GetStream(string path, FileMode mode, int tries, int sleeptime) + { + // Normalise path + path = Path.GetFullPath(path); + + if (_streams.TryGetValue(path, out var stream)) + return stream; + + stream = SqexFileStream.WaitForStream(path, mode, tries, sleeptime); + _streams.Add(path, stream); + + return stream; + } + + public void Dispose() + { + foreach (var stream in _streams.Values) + stream.Dispose(); + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqpackDatFile.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqpackDatFile.cs new file mode 100644 index 0000000..8f588bf --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqpackDatFile.cs @@ -0,0 +1,37 @@ +using System.IO; +using System.Text; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Util +{ + class SqpackDatFile : SqpackFile + { + public SqpackDatFile(BinaryReader reader) : base(reader) {} + + + protected override string GetFileName(ZiPatchConfig.PlatformId platform) => + $"{base.GetFileName(platform)}.dat{FileId}"; + + + public static void WriteEmptyFileBlockAt(SqexFileStream stream, int offset, int blockNumber) + { + stream.WipeFromOffset(blockNumber << 7, offset); + stream.Position = offset; + + using (var file = new BinaryWriter(stream, Encoding.Default, true)) + { + // FileBlockHeader - the 0 writes are technically unnecessary but are in for illustrative purposes + + // Block size + file.Write(1 << 7); + // ???? + file.Write(0); + // File size + file.Write(0); + // Total number of blocks? + file.Write(blockNumber - 1); + // Used number of blocks? + file.Write(0); + } + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqpackFile.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqpackFile.cs new file mode 100644 index 0000000..43fb403 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqpackFile.cs @@ -0,0 +1,38 @@ +using System.IO; +using XIVLauncher2.Common.Patching.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Util +{ + public abstract class SqpackFile : SqexFile + { + protected ushort MainId { get; } + protected ushort SubId { get; } + protected uint FileId { get; } + + protected byte ExpansionId => (byte)(SubId >> 8); + + protected SqpackFile(BinaryReader reader) + { + MainId = reader.ReadUInt16BE(); + SubId = reader.ReadUInt16BE(); + FileId = reader.ReadUInt32BE(); + + RelativePath = GetExpansionPath(); + } + + protected string GetExpansionPath() => + $@"/sqpack/{GetExpansionFolder(ExpansionId)}/"; + + protected virtual string GetFileName(ZiPatchConfig.PlatformId platform) => + $"{GetExpansionPath()}{MainId:x2}{SubId:x4}.{platform.ToString().ToLower()}"; + + public void ResolvePath(ZiPatchConfig.PlatformId platform) => + RelativePath = GetFileName(platform); + + public override string ToString() + { + // Default to Win32 for prints; we're unlikely to run in PS3/PS4 + return GetFileName(ZiPatchConfig.PlatformId.Win32); + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqpackIndexFile.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqpackIndexFile.cs new file mode 100644 index 0000000..1494e0a --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqpackIndexFile.cs @@ -0,0 +1,13 @@ +using System.IO; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Util +{ + class SqpackIndexFile : SqpackFile + { + public SqpackIndexFile(BinaryReader reader) : base(reader) {} + + + protected override string GetFileName(ZiPatchConfig.PlatformId platform) => + $"{base.GetFileName(platform)}.index{(FileId == 0 ? string.Empty : FileId.ToString())}"; + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqpkCompressedBlock.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqpkCompressedBlock.cs new file mode 100644 index 0000000..a2446e2 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/Util/SqpkCompressedBlock.cs @@ -0,0 +1,45 @@ +using System.IO; +using System.IO.Compression; + +namespace XIVLauncher2.Common.Patching.ZiPatch.Util +{ + class SqpkCompressedBlock + { + public int HeaderSize { get; protected set; } + public int CompressedSize { get; protected set; } + public int DecompressedSize { get; protected set; } + + public bool IsCompressed => CompressedSize != 0x7d00; + public int CompressedBlockLength => (int)(((IsCompressed ? CompressedSize : DecompressedSize) + 143) & 0xFFFF_FF80); + + public byte[] CompressedBlock { get; protected set; } + + public SqpkCompressedBlock(BinaryReader reader) + { + HeaderSize = reader.ReadInt32(); + reader.ReadUInt32(); // Pad + + CompressedSize = reader.ReadInt32(); + DecompressedSize = reader.ReadInt32(); + + if (IsCompressed) + CompressedBlock = reader.ReadBytes(CompressedBlockLength - HeaderSize); + else + { + CompressedBlock = reader.ReadBytes(DecompressedSize); + + reader.ReadBytes(CompressedBlockLength - HeaderSize - DecompressedSize); + } + } + + public void DecompressInto(Stream outStream) + { + if (IsCompressed) + using (var stream = new DeflateStream(new MemoryStream(CompressedBlock), CompressionMode.Decompress)) + stream.CopyTo(outStream); + else + using (var stream = new MemoryStream(CompressedBlock)) + stream.CopyTo(outStream); + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/ZiPatchConfig.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/ZiPatchConfig.cs new file mode 100644 index 0000000..46b761e --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/ZiPatchConfig.cs @@ -0,0 +1,27 @@ +using XIVLauncher2.Common.Patching.ZiPatch.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch +{ + public class ZiPatchConfig + { + public enum PlatformId : ushort + { + Win32 = 0, + Ps3 = 1, + Ps4 = 2, + Unknown = 3 + } + + public string GamePath { get; protected set; } + public PlatformId Platform { get; set; } + public bool IgnoreMissing { get; set; } + public bool IgnoreOldMismatch { get; set; } + public SqexFileStreamStore Store { get; set; } + + + public ZiPatchConfig(string gamePath) + { + GamePath = gamePath; + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/ZiPatchException.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/ZiPatchException.cs new file mode 100644 index 0000000..9ffe312 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/ZiPatchException.cs @@ -0,0 +1,11 @@ +using System; + +namespace XIVLauncher2.Common.Patching.ZiPatch +{ + public class ZiPatchException : Exception + { + public ZiPatchException(string message = "ZiPatch error", Exception? innerException = null) : base(message, innerException) + { + } + } +} diff --git a/src/XIVLauncher2.Common/Patching/ZiPatch/ZiPatchFile.cs b/src/XIVLauncher2.Common/Patching/ZiPatch/ZiPatchFile.cs new file mode 100644 index 0000000..51327f8 --- /dev/null +++ b/src/XIVLauncher2.Common/Patching/ZiPatch/ZiPatchFile.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using XIVLauncher2.Common.Patching.ZiPatch.Chunk; +using XIVLauncher2.Common.Patching.ZiPatch.Util; + +namespace XIVLauncher2.Common.Patching.ZiPatch +{ + public class ZiPatchFile : IDisposable + { + private static readonly uint[] zipatchMagic = + { + 0x50495A91, 0x48435441, 0x0A1A0A0D + }; + + private readonly Stream _stream; + + + /// + /// Instantiates a ZiPatchFile from a Stream + /// + /// Stream to a ZiPatch + public ZiPatchFile(Stream stream) + { + this._stream = stream; + + var reader = new BinaryReader(stream); + if (zipatchMagic.Any(magic => magic != reader.ReadUInt32())) + throw new ZiPatchException(); + } + + /// + /// Instantiates a ZiPatchFile from a file path + /// + /// Path to patch file + public static ZiPatchFile FromFileName(string filepath) + { + var stream = SqexFileStream.WaitForStream(filepath, FileMode.Open); + return new ZiPatchFile(stream); + } + + + + public IEnumerable GetChunks() + { + ZiPatchChunk chunk; + do + { + chunk = ZiPatchChunk.GetChunk(_stream); + + yield return chunk; + } while (chunk.ChunkType != EndOfFileChunk.Type); + } + + public void Dispose() + { + _stream?.Dispose(); + } + } +} diff --git a/src/XIVLauncher2.Common/Paths.cs b/src/XIVLauncher2.Common/Paths.cs new file mode 100644 index 0000000..e083b90 --- /dev/null +++ b/src/XIVLauncher2.Common/Paths.cs @@ -0,0 +1,22 @@ +using System; +using System.IO; + +namespace XIVLauncher2.Common +{ + public class Paths + { + static Paths() + { + RoamingPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "XIVLauncher"); + } + + public static string RoamingPath { get; private set; } + + public static string ResourcesPath => Path.Combine(AppContext.BaseDirectory, "Resources"); + + public static void OverrideRoamingPath(string path) + { + RoamingPath = Environment.ExpandEnvironmentVariables(path); + } + } +} diff --git a/src/XIVLauncher2.Common/Platform.cs b/src/XIVLauncher2.Common/Platform.cs new file mode 100644 index 0000000..9bc9d18 --- /dev/null +++ b/src/XIVLauncher2.Common/Platform.cs @@ -0,0 +1,10 @@ +namespace XIVLauncher2.Common +{ + public enum Platform + { + Win32, + Win32OnLinux, + Linux, + Mac, + } +} diff --git a/src/XIVLauncher2.Common/PlatformAbstractions/IDalamudCompatibilityCheck.cs b/src/XIVLauncher2.Common/PlatformAbstractions/IDalamudCompatibilityCheck.cs new file mode 100644 index 0000000..db3a9ed --- /dev/null +++ b/src/XIVLauncher2.Common/PlatformAbstractions/IDalamudCompatibilityCheck.cs @@ -0,0 +1,20 @@ +using System; + +namespace XIVLauncher2.Common.PlatformAbstractions; + +public interface IDalamudCompatibilityCheck +{ + public void EnsureCompatibility(); + + public class ArchitectureNotSupportedException : Exception + { + public ArchitectureNotSupportedException(string message) + : base(message) + { + } + } + + public class NoRedistsException : Exception + { + } +} diff --git a/src/XIVLauncher2.Common/PlatformAbstractions/IDalamudLoadingOverlay.cs b/src/XIVLauncher2.Common/PlatformAbstractions/IDalamudLoadingOverlay.cs new file mode 100644 index 0000000..12b1b30 --- /dev/null +++ b/src/XIVLauncher2.Common/PlatformAbstractions/IDalamudLoadingOverlay.cs @@ -0,0 +1,21 @@ +namespace XIVLauncher2.Common.PlatformAbstractions; + +public interface IDalamudLoadingOverlay +{ + public enum DalamudUpdateStep + { + Dalamud, + Assets, + Runtime, + Unavailable, + Starting, + } + + public void SetStep(DalamudUpdateStep step); + + public void SetVisible(); + + public void SetInvisible(); + + public void ReportProgress(long? size, long downloaded, double? progress); +} diff --git a/src/XIVLauncher2.Common/PlatformAbstractions/IDalamudRunner.cs b/src/XIVLauncher2.Common/PlatformAbstractions/IDalamudRunner.cs new file mode 100644 index 0000000..f9758a6 --- /dev/null +++ b/src/XIVLauncher2.Common/PlatformAbstractions/IDalamudRunner.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using XIVLauncher2.Common.Dalamud; + +namespace XIVLauncher2.Common.PlatformAbstractions; + +public interface IDalamudRunner +{ + Process? Run(FileInfo runner, bool fakeLogin, bool noPlugins, bool noThirdPlugins, FileInfo gameExe, string gameArgs, IDictionary environment, DalamudLoadMethod loadMethod, DalamudStartInfo startInfo); +} diff --git a/src/XIVLauncher2.Common/PlatformAbstractions/IGameRunner.cs b/src/XIVLauncher2.Common/PlatformAbstractions/IGameRunner.cs new file mode 100644 index 0000000..80837c5 --- /dev/null +++ b/src/XIVLauncher2.Common/PlatformAbstractions/IGameRunner.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using System.Diagnostics; + +namespace XIVLauncher2.Common.PlatformAbstractions; + +public interface IGameRunner +{ + Process? Start(string path, string workingDirectory, string arguments, IDictionary environment, DpiAwareness dpiAwareness); +} diff --git a/src/XIVLauncher2.Common/PlatformAbstractions/ISettings.cs b/src/XIVLauncher2.Common/PlatformAbstractions/ISettings.cs new file mode 100644 index 0000000..f6f3c6e --- /dev/null +++ b/src/XIVLauncher2.Common/PlatformAbstractions/ISettings.cs @@ -0,0 +1,16 @@ +using System.IO; +using XIVLauncher2.Common.Game.Patch.Acquisition; + +namespace XIVLauncher2.Common.PlatformAbstractions; + +public interface ISettings +{ + string AcceptLanguage { get; } + ClientLanguage? ClientLanguage { get; } + bool? KeepPatches { get; } + DirectoryInfo PatchPath { get; } + DirectoryInfo GamePath { get; } + AcquisitionMethod? PatchAcquisitionMethod { get; } + long SpeedLimitBytes { get; } + int DalamudInjectionDelayMs { get; } +} diff --git a/src/XIVLauncher2.Common/PlatformAbstractions/ISteam.cs b/src/XIVLauncher2.Common/PlatformAbstractions/ISteam.cs new file mode 100644 index 0000000..94d3538 --- /dev/null +++ b/src/XIVLauncher2.Common/PlatformAbstractions/ISteam.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; + +namespace XIVLauncher2.Common.PlatformAbstractions; + +public interface ISteam +{ + void Initialize(uint appId); + bool IsValid { get; } + bool BLoggedOn { get; } + bool BOverlayNeedsPresent { get; } + void Shutdown(); + Task GetAuthSessionTicketAsync(); + bool IsAppInstalled(uint appId); + string GetAppInstallDir(uint appId); + bool ShowGamepadTextInput(bool password, bool multiline, string description, int maxChars, string existingText = ""); + string GetEnteredGamepadText(); + bool ShowFloatingGamepadTextInput(EFloatingGamepadTextInputMode mode, int x, int y, int width, int height); + bool IsRunningOnSteamDeck(); + uint GetServerRealTime(); + public void ActivateGameOverlayToWebPage(string url, bool modal = false); + + enum EFloatingGamepadTextInputMode + { + EnterDismisses, + UserDismisses, + Email, + Numeric, + } + + event Action OnGamepadTextInputDismissed; +} diff --git a/src/XIVLauncher2.Common/PlatformAbstractions/IUniqueIdCache.cs b/src/XIVLauncher2.Common/PlatformAbstractions/IUniqueIdCache.cs new file mode 100644 index 0000000..eb5d1d8 --- /dev/null +++ b/src/XIVLauncher2.Common/PlatformAbstractions/IUniqueIdCache.cs @@ -0,0 +1,19 @@ +namespace XIVLauncher2.Common.PlatformAbstractions; + +public interface IUniqueIdCache +{ + bool HasValidCache(string name); + + void Add(string name, string uid, int region, int maxExpansion); + + bool TryGet(string userName, out CachedUid cached); + + void Reset(); + + public struct CachedUid + { + public string UniqueId; + public int Region; + public int MaxExpansion; + } +} diff --git a/src/XIVLauncher2.Common/Repository.cs b/src/XIVLauncher2.Common/Repository.cs new file mode 100644 index 0000000..084cc42 --- /dev/null +++ b/src/XIVLauncher2.Common/Repository.cs @@ -0,0 +1,113 @@ +using System; +using System.IO; +using System.Text; + +namespace XIVLauncher2.Common +{ + public enum Repository + { + Boot, + Ffxiv, + Ex1, + Ex2, + Ex3, + Ex4 + } + + public static class RepoExtensions + { + private static DirectoryInfo GetRepoPath(this Repository repo, DirectoryInfo gamePath) + { + switch (repo) + { + case Repository.Boot: + return new DirectoryInfo(Path.Combine(gamePath.FullName, "boot")); + case Repository.Ffxiv: + return new DirectoryInfo(Path.Combine(gamePath.FullName, "game")); + case Repository.Ex1: + return new DirectoryInfo(Path.Combine(gamePath.FullName, "game", "sqpack", "ex1")); + case Repository.Ex2: + return new DirectoryInfo(Path.Combine(gamePath.FullName, "game", "sqpack", "ex2")); + case Repository.Ex3: + return new DirectoryInfo(Path.Combine(gamePath.FullName, "game", "sqpack", "ex3")); + case Repository.Ex4: + return new DirectoryInfo(Path.Combine(gamePath.FullName, "game", "sqpack", "ex4")); + default: + throw new ArgumentOutOfRangeException(nameof(repo), repo, null); + } + } + + public static FileInfo GetVerFile(this Repository repo, DirectoryInfo gamePath, bool isBck = false) + { + var repoPath = repo.GetRepoPath(gamePath).FullName; + switch (repo) + { + case Repository.Boot: + return new FileInfo(Path.Combine(repoPath, "ffxivboot" + (isBck ? ".bck" : ".ver"))); + case Repository.Ffxiv: + return new FileInfo(Path.Combine(repoPath, "ffxivgame" + (isBck ? ".bck" : ".ver"))); + case Repository.Ex1: + return new FileInfo(Path.Combine(repoPath, "ex1" + (isBck ? ".bck" : ".ver"))); + case Repository.Ex2: + return new FileInfo(Path.Combine(repoPath, "ex2" + (isBck ? ".bck" : ".ver"))); + case Repository.Ex3: + return new FileInfo(Path.Combine(repoPath, "ex3" + (isBck ? ".bck" : ".ver"))); + case Repository.Ex4: + return new FileInfo(Path.Combine(repoPath, "ex4" + (isBck ? ".bck" : ".ver"))); + default: + throw new ArgumentOutOfRangeException(nameof(repo), repo, null); + } + } + + public static string GetVer(this Repository repo, DirectoryInfo gamePath, bool isBck = false) + { + var verFile = repo.GetVerFile(gamePath, isBck); + + if (!verFile.Exists) + return Constants.BASE_GAME_VERSION; + + var ver = File.ReadAllText(verFile.FullName); + return string.IsNullOrWhiteSpace(ver) ? Constants.BASE_GAME_VERSION : ver; + } + + public static void SetVer(this Repository repo, DirectoryInfo gamePath, string newVer, bool isBck = false) + { + var verFile = GetVerFile(repo, gamePath, isBck); + + if (!verFile.Directory.Exists) + verFile.Directory.Create(); + + using var fileStream = verFile.Open(FileMode.Create, FileAccess.Write, FileShare.None); + var buffer = Encoding.ASCII.GetBytes(newVer); + fileStream.Write(buffer, 0, buffer.Length); + fileStream.Flush(); + } + + public static bool IsBaseVer(this Repository repo, DirectoryInfo gamePath) + { + return repo.GetVer(gamePath) == Constants.BASE_GAME_VERSION; + } + + // TODO + public static string GetRepoHash(this Repository repo) + { + switch (repo) + { + case Repository.Boot: + return null; + case Repository.Ffxiv: + return null; + case Repository.Ex1: + return null; + case Repository.Ex2: + return null; + case Repository.Ex3: + return null; + case Repository.Ex4: + return null; + default: + throw new ArgumentOutOfRangeException(nameof(repo), repo, null); + } + } + } +} diff --git a/src/XIVLauncher2.Common/SeVersion.cs b/src/XIVLauncher2.Common/SeVersion.cs new file mode 100644 index 0000000..0ebea05 --- /dev/null +++ b/src/XIVLauncher2.Common/SeVersion.cs @@ -0,0 +1,88 @@ +using System; + +namespace XIVLauncher2.Common +{ + public class SeVersion : IComparable + { + public uint Year { get; set; } + public uint Month { get; set; } + public uint Day { get; set; } + public uint Revision { get; set; } + public uint Part { get; set; } + + public static SeVersion Parse(string input) + { + var parts = input.Split('.'); + return new SeVersion + { + Year = uint.Parse(parts[0]), + Month = uint.Parse(parts[1]), + Day = uint.Parse(parts[2]), + Revision = uint.Parse(parts[3]), + Part = uint.Parse(parts[4]), + }; + } + + public override string ToString() => $"{Year:0000}.{Month:00}.{Day:00}.{Revision:0000}.{Part:0000}"; + + public int CompareTo(object obj) + { + var other = obj as SeVersion; + if (other == null) + return 1; + + if (Year > other.Year) + return 1; + + if (Year < other.Year) + return -1; + + if (Month > other.Month) + return 1; + + if (Month < other.Month) + return -1; + + if (Day > other.Day) + return 1; + + if (Day < other.Day) + return -1; + + if (Revision > other.Revision) + return 1; + + if (Revision < other.Revision) + return -1; + + if (Part > other.Part) + return 1; + + if (Part < other.Part) + return -1; + + return 0; + } + + public static bool operator <(SeVersion x, SeVersion y) => x.CompareTo(y) < 0; + public static bool operator >(SeVersion x, SeVersion y) => x.CompareTo(y) > 0; + public static bool operator <=(SeVersion x, SeVersion y) => x.CompareTo(y) <= 0; + public static bool operator >=(SeVersion x, SeVersion y) => x.CompareTo(y) >= 0; + + public static bool operator ==(SeVersion x, SeVersion y) + { + if (x is null) + return y is null; + + return x.CompareTo(y) == 0; + } + + public static bool operator !=(SeVersion x, SeVersion y) + { + if (x is null) + return y != null; + + return x.CompareTo(y) != 0; + } + } +} diff --git a/src/XIVLauncher2.Common/SettingsAnnotation.cs b/src/XIVLauncher2.Common/SettingsAnnotation.cs new file mode 100644 index 0000000..f234fa0 --- /dev/null +++ b/src/XIVLauncher2.Common/SettingsAnnotation.cs @@ -0,0 +1,17 @@ +using System; + +namespace XIVLauncher2.Common; + +[AttributeUsage(AttributeTargets.Field)] +public class SettingsDescriptionAttribute : Attribute +{ + public string FriendlyName { get; set; } + + public string Description { get; set; } + + public SettingsDescriptionAttribute(string friendlyName, string description) + { + this.FriendlyName = friendlyName; + this.Description = description; + } +} diff --git a/src/XIVLauncher2.Common/Storage.cs b/src/XIVLauncher2.Common/Storage.cs new file mode 100644 index 0000000..154fedd --- /dev/null +++ b/src/XIVLauncher2.Common/Storage.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; + +namespace XIVLauncher2.Common; + +public class Storage +{ + public DirectoryInfo Root { get; } + + public Storage(string appName, string? overridePath = null) + { + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + this.Root = new DirectoryInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), appName)); + } + else + { + this.Root = new DirectoryInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), $".{appName}")); + } + + if (!string.IsNullOrEmpty(overridePath)) + { + this.Root = new DirectoryInfo(overridePath); + } + + if (!this.Root.Exists) + this.Root.Create(); + } + + public FileInfo GetFile(string fileName) + { + return new FileInfo(Path.Combine(this.Root.FullName, fileName)); + } + + /// + /// Gets a folder and makes sure that it exists. + /// + /// + /// + public DirectoryInfo GetFolder(string folderName) + { + var folder = new DirectoryInfo(Path.Combine(this.Root.FullName, folderName)); + + if (!folder.Exists) + folder.Create(); + + return folder; + } +} diff --git a/src/XIVLauncher2.Common/Support/LogInit.cs b/src/XIVLauncher2.Common/Support/LogInit.cs new file mode 100644 index 0000000..ba5e456 --- /dev/null +++ b/src/XIVLauncher2.Common/Support/LogInit.cs @@ -0,0 +1,68 @@ +using System.IO; +using System.Linq; +using CommandLine; +using Serilog; + +namespace XIVLauncher2.Common.Support; + +public static class LogInit +{ + // ReSharper disable once ClassNeverInstantiated.Local + private class LogOptions + { + [Option('v', "verbose", Required = false, HelpText = "Set output to verbose messages.")] + public bool Verbose { get; set; } + + [Option("log-file-path", Required = false, HelpText = "Set path for log file.")] + public string? LogPath { get; set; } + } + + public static void Setup(string defaultLogPath, string[] args) + { + ParserResult result = null; + + try + { + var parser = new Parser(c => { c.IgnoreUnknownArguments = true; }); + result = parser.ParseArguments(args); + } + catch + { +#if DEBUG + throw; +#endif + } + + var config = new LoggerConfiguration() + .WriteTo.Sink(SerilogEventSink.Instance); + + var parsed = result?.Value ?? new LogOptions(); + + if (!string.IsNullOrEmpty(parsed.LogPath)) + { + config.WriteTo.Async(a => + { + a.File(parsed.LogPath); + }); + } + else + { + config.WriteTo.Async(a => + { + a.File(defaultLogPath); + }); + } + +#if DEBUG + config.WriteTo.Debug(); + config.MinimumLevel.Verbose(); +#else + config.MinimumLevel.Information(); +#endif + + if (parsed.Verbose) + config.MinimumLevel.Verbose(); + + Log.Logger = config.CreateLogger(); + } +} diff --git a/src/XIVLauncher2.Common/Support/SerilogEventSink.cs b/src/XIVLauncher2.Common/Support/SerilogEventSink.cs new file mode 100644 index 0000000..9c69d42 --- /dev/null +++ b/src/XIVLauncher2.Common/Support/SerilogEventSink.cs @@ -0,0 +1,50 @@ +using System; +using Serilog.Core; +using Serilog.Events; + +namespace XIVLauncher2.Common.Support +{ + /// + /// Serilog event sink. + /// + public class SerilogEventSink : ILogEventSink + { + private static SerilogEventSink instance; + private readonly IFormatProvider formatProvider; + + /// + /// Initializes a new instance of the class. + /// + /// Logging format provider. + private SerilogEventSink(IFormatProvider formatProvider) + { + this.formatProvider = formatProvider; + } + + /// + /// Event on a log line being emitted. + /// + public event EventHandler<(string Line, LogEventLevel Level, DateTimeOffset TimeStamp, Exception? Exception)>? LogLine; + + /// + /// Gets the default instance. + /// + public static SerilogEventSink Instance => instance ??= new SerilogEventSink(null); + + /// + /// Emit a log event. + /// + /// Log event to be emitted. + public void Emit(LogEvent logEvent) + { + var message = logEvent.RenderMessage(this.formatProvider); + + if (logEvent.Exception != null) + { + message += "\n" + logEvent.Exception; + } + + this.LogLine?.Invoke(this, (message, logEvent.Level, logEvent.Timestamp, logEvent.Exception)); + } + } +} diff --git a/src/XIVLauncher2.Common/Util/ApiHelpers.cs b/src/XIVLauncher2.Common/Util/ApiHelpers.cs new file mode 100644 index 0000000..86e5594 --- /dev/null +++ b/src/XIVLauncher2.Common/Util/ApiHelpers.cs @@ -0,0 +1,80 @@ +using System; +using System.Linq; +using System.Net.Http.Headers; + +namespace XIVLauncher2.Common.Util; + +public static class ApiHelpers +{ + public static long GetUnixMillis() + { + return (long)DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1)).TotalMilliseconds; + } + + public static string BytesToString(double byteCount) => BytesToString(Convert.ToInt64(Math.Floor(byteCount))); + + public static string BytesToString(long byteCount) + { + string[] suf = { "B", "KB", "MB", "GB", "TB", "PB", "EB" }; //Longs run out around EB + + if (byteCount == 0) + return "0" + suf[0]; + + var bytes = Math.Abs(byteCount); + var place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024))); + var num = Math.Round(bytes / Math.Pow(1024, place), 1); + return $"{(Math.Sign(byteCount) * num):#0.0}{suf[place]}"; + } + + public static string GenerateAcceptLanguage(int asdf = 0) + { + var codes = new string[] { "de-DE", "en-US", "ja" }; + var codesMany = new string[] { "de-DE", "en-US,en", "en-GB,en", "fr-BE,fr", "ja", "fr-FR,fr", "fr-CH,fr" }; + var rng = new Random(asdf); + + var many = rng.Next(10) < 3; + + if (many) + { + var howMany = rng.Next(2, 4); + var deck = codesMany.OrderBy((x) => rng.Next()).Take(howMany).ToArray(); + + var hdr = string.Empty; + + for (int i = 0; i < deck.Count(); i++) + { + hdr += deck.ElementAt(i) + $";q=0.{10 - (i + 1)}"; + + if (i != deck.Length - 1) + hdr += ";"; + } + + return hdr; + } + + return codes[rng.Next(0, codes.Length)]; + } + + public static void AddWithoutValidation(this HttpHeaders headers, string key, string value) + { + var res = headers.TryAddWithoutValidation(key, value); + + if (!res) + throw new InvalidOperationException($"Could not add header - {key}: {value}"); + } + + /// + /// Gets an attribute on an enum. + /// + /// The type of attribute to get. + /// The enum value that has an attached attribute. + /// The attached attribute, if any. + public static TAttribute? GetAttribute(this Enum value) + where TAttribute : Attribute + { + var type = value.GetType(); + var memInfo = type.GetMember(value.ToString()); + var attributes = memInfo[0].GetCustomAttributes(typeof(TAttribute), false); + return (attributes.Length > 0) ? (TAttribute)attributes[0] : null; + } +} diff --git a/src/XIVLauncher2.Common/Util/DebugHelpers.cs b/src/XIVLauncher2.Common/Util/DebugHelpers.cs new file mode 100644 index 0000000..955ae75 --- /dev/null +++ b/src/XIVLauncher2.Common/Util/DebugHelpers.cs @@ -0,0 +1,73 @@ +using System; +using System.Text; + +namespace XIVLauncher2.Common.Util; + +public static class DebugHelpers +{ + /// + /// Create a hexdump of the provided bytes. + /// + /// The bytes to hexdump. + /// The offset in the byte array to start at. + /// The amount of bytes to display per line. + /// The generated hexdump in string form. + public static string ByteArrayToHex(byte[] bytes, int offset = 0, int bytesPerLine = 16) + { + if (bytes == null) return string.Empty; + + var hexChars = "0123456789ABCDEF".ToCharArray(); + + const int OFFSET_BLOCK = 8 + 3; + var byteBlock = OFFSET_BLOCK + (bytesPerLine * 3) + ((bytesPerLine - 1) / 8) + 2; + var lineLength = byteBlock + bytesPerLine + Environment.NewLine.Length; + + var line = (new string(' ', lineLength - Environment.NewLine.Length) + Environment.NewLine).ToCharArray(); + var numLines = (bytes.Length + bytesPerLine - 1) / bytesPerLine; + + var sb = new StringBuilder(numLines * lineLength); + + for (var i = 0; i < bytes.Length; i += bytesPerLine) + { + var h = i + offset; + + line[0] = hexChars[(h >> 28) & 0xF]; + line[1] = hexChars[(h >> 24) & 0xF]; + line[2] = hexChars[(h >> 20) & 0xF]; + line[3] = hexChars[(h >> 16) & 0xF]; + line[4] = hexChars[(h >> 12) & 0xF]; + line[5] = hexChars[(h >> 8) & 0xF]; + line[6] = hexChars[(h >> 4) & 0xF]; + line[7] = hexChars[(h >> 0) & 0xF]; + + var hexColumn = OFFSET_BLOCK; + var charColumn = byteBlock; + + for (var j = 0; j < bytesPerLine; j++) + { + if (j > 0 && (j & 7) == 0) hexColumn++; + + if (i + j >= bytes.Length) + { + line[hexColumn] = ' '; + line[hexColumn + 1] = ' '; + line[charColumn] = ' '; + } + else + { + var by = bytes[i + j]; + line[hexColumn] = hexChars[(by >> 4) & 0xF]; + line[hexColumn + 1] = hexChars[by & 0xF]; + line[charColumn] = by < 32 ? '.' : (char)by; + } + + hexColumn += 3; + charColumn++; + } + + sb.Append(line); + } + + return sb.ToString().TrimEnd(Environment.NewLine.ToCharArray()); + } +} diff --git a/src/XIVLauncher2.Common/Util/GameHelpers.cs b/src/XIVLauncher2.Common/Util/GameHelpers.cs new file mode 100644 index 0000000..1b9942d --- /dev/null +++ b/src/XIVLauncher2.Common/Util/GameHelpers.cs @@ -0,0 +1,105 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; + +namespace XIVLauncher2.Common.Util; + +public static class GameHelpers +{ + /// + /// Returns if the current system region is set to North America. + /// + public static bool IsRegionNorthAmerica() + { + return RegionInfo.CurrentRegion.TwoLetterISORegionName is "US" or "MX" or "CA"; + } + + public static bool IsValidGamePath(string path) + { + if (string.IsNullOrEmpty(path)) + return false; + + return Directory.Exists(Path.Combine(path, "game")) && Directory.Exists(Path.Combine(path, "boot")); + } + + public static bool CanMightNotBeInternationalClient(string path) + { + if (Directory.Exists(Path.Combine(path, "sdo"))) + return true; + + if (File.Exists(Path.Combine(path, "boot", "FFXIV_Boot.exe"))) + return true; + + return false; + } + + public static bool LetChoosePath(string path) + { + if (string.IsNullOrEmpty(path)) + return true; + + var di = new DirectoryInfo(path); + + if (di.Name == "game") + return false; + + if (di.Name == "boot") + return false; + + if (di.Name == "sqpack") + return false; + + return true; + } + + public static FileInfo GetOfficialLauncherPath(DirectoryInfo gamePath) => new(Path.Combine(gamePath.FullName, "boot", "ffxivboot.exe")); + + public static void StartOfficialLauncher(DirectoryInfo gamePath, bool isSteam, bool isFreeTrial) + { + var args = string.Empty; + + if (isSteam && isFreeTrial) + { + args = "-issteamfreetrial"; + } + else if (isSteam) + { + args = "-issteam"; + } + + Process.Start(GetOfficialLauncherPath(gamePath).FullName, args); + } + + public static bool CheckIsGameOpen() + { +#if DEBUG + return false; +#endif + + var procs = Process.GetProcesses(); + + if (procs.Any(x => x.ProcessName == "ffxiv")) + return true; + + if (procs.Any(x => x.ProcessName == "ffxiv_dx11")) + return true; + + if (procs.Any(x => x.ProcessName == "ffxivboot")) + return true; + + if (procs.Any(x => x.ProcessName == "ffxivlauncher")) + return true; + + return false; + } + + public static string ToMangledSeBase64(byte[] input) + { + return Convert.ToBase64String(input) + .Replace('+', '-') + .Replace('/', '_') + .Replace('=', '*'); + } +} diff --git a/src/XIVLauncher2.Common/Util/HttpClientWithProgress.cs b/src/XIVLauncher2.Common/Util/HttpClientWithProgress.cs new file mode 100644 index 0000000..44a283d --- /dev/null +++ b/src/XIVLauncher2.Common/Util/HttpClientWithProgress.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; + +namespace XIVLauncher2.Common.Util; + +public class HttpClientDownloadWithProgress : IDisposable +{ + private readonly string downloadUrl; + private readonly string destinationFilePath; + + private HttpClient httpClient; + + public delegate void ProgressChangedHandler(long? totalFileSize, long totalBytesDownloaded, double? progressPercentage); + + public event ProgressChangedHandler ProgressChanged; + + public HttpClientDownloadWithProgress(string downloadUrl, string destinationFilePath) + { + this.downloadUrl = downloadUrl; + this.destinationFilePath = destinationFilePath; + } + + public async Task Download(TimeSpan? timeout = null) + { + timeout ??= TimeSpan.FromDays(1); + this.httpClient = new HttpClient { Timeout = timeout.Value }; + + using var response = await this.httpClient.GetAsync(this.downloadUrl, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); + await this.DownloadFileFromHttpResponseMessage(response).ConfigureAwait(false); + } + + private async Task DownloadFileFromHttpResponseMessage(HttpResponseMessage response) + { + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength; + + using var contentStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await this.ProcessContentStream(totalBytes, contentStream).ConfigureAwait(false); + } + + private async Task ProcessContentStream(long? totalDownloadSize, Stream contentStream) + { + var totalBytesRead = 0L; + var readCount = 0L; + var buffer = new byte[8192]; + var isMoreToRead = true; + + using var fileStream = new FileStream(this.destinationFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true); + + do + { + var bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + + if (bytesRead == 0) + { + isMoreToRead = false; + this.TriggerProgressChanged(totalDownloadSize, totalBytesRead); + continue; + } + + await fileStream.WriteAsync(buffer, 0, bytesRead).ConfigureAwait(false); + + totalBytesRead += bytesRead; + readCount += 1; + + if (readCount % 100 == 0) + this.TriggerProgressChanged(totalDownloadSize, totalBytesRead); + } while (isMoreToRead); + } + + private void TriggerProgressChanged(long? totalDownloadSize, long totalBytesRead) + { + if (this.ProgressChanged == null) + return; + + double? progressPercentage = null; + if (totalDownloadSize.HasValue) + progressPercentage = Math.Round((double)totalBytesRead / totalDownloadSize.Value * 100, 2); + + this.ProgressChanged(totalDownloadSize, totalBytesRead, progressPercentage); + } + + public void Dispose() + { + this.httpClient?.Dispose(); + } +} diff --git a/src/XIVLauncher2.Common/Util/PlatformHelpers.cs b/src/XIVLauncher2.Common/Util/PlatformHelpers.cs new file mode 100644 index 0000000..3176e8d --- /dev/null +++ b/src/XIVLauncher2.Common/Util/PlatformHelpers.cs @@ -0,0 +1,148 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace XIVLauncher2.Common.Util; + +public static class PlatformHelpers +{ + public static Platform GetPlatform() + { + if (EnvironmentSettings.IsWine) + return Platform.Win32OnLinux; + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return Platform.Linux; + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return Platform.Mac; + else + return Platform.Win32; + + // TODO(goat): Add mac here, once it's merged + + + } + + /// + /// Generates a temporary file name. + /// + /// A temporary file name that is almost guaranteed to be unique. + public static string GetTempFileName() + { + // https://stackoverflow.com/a/50413126 + return Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + } + + public static void OpenBrowser(string url) + { + // https://github.com/dotnet/corefx/issues/10361 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + url = url.Replace("&", "^&"); + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Process.Start("xdg-open", url); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Process.Start("open", url); + } + else + { + throw new NotImplementedException(); + } + } + + [DllImport("libc")] + private static extern uint geteuid(); + + public static bool IsElevated() + { + switch (Environment.OSVersion.Platform) + { + case PlatformID.Win32NT: + return new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); + + case PlatformID.Unix: + return geteuid() == 0; + + default: + return false; + } + } + + public static void Untar(string path, string output) + { + var psi = new ProcessStartInfo("tar") + { + Arguments = $"-xf \"{path}\" -C \"{output}\"" + }; + + var tarProcess = Process.Start(psi); + + if (tarProcess == null) + throw new Exception("Could not start tar."); + + tarProcess.WaitForExit(); + + if (tarProcess.ExitCode != 0) + throw new Exception("Could not untar."); + } + + private static readonly IPEndPoint DefaultLoopbackEndpoint = new(IPAddress.Loopback, port: 0); + + public static int GetAvailablePort() + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + + socket.Bind(DefaultLoopbackEndpoint); + return ((IPEndPoint)socket.LocalEndPoint).Port; + } + +#if WIN32 + /* + * WINE: The APIs DriveInfo uses are buggy on Wine. Let's just use the kernel32 API instead. + */ + + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + public static extern bool GetDiskFreeSpaceEx(string lpDirectoryName, + out ulong lpFreeBytesAvailable, + out ulong lpTotalNumberOfBytes, + out ulong lpTotalNumberOfFreeBytes); + + public static long GetDiskFreeSpace(DirectoryInfo info) + { + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + + ulong dummy = 0; + + if (!GetDiskFreeSpaceEx(info.Root.FullName, out ulong freeSpace, out dummy, out dummy)) + { + throw new System.ComponentModel.Win32Exception(System.Runtime.InteropServices.Marshal.GetLastWin32Error()); + } + + return (long)freeSpace; + } +#else + public static long GetDiskFreeSpace(DirectoryInfo info) + { + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + + DriveInfo drive = new DriveInfo(info.FullName); + + return drive.AvailableFreeSpace; + } +#endif +} diff --git a/src/XIVLauncher2.Common/XIVLauncher2.Common.csproj b/src/XIVLauncher2.Common/XIVLauncher2.Common.csproj index 56f9256..a8c8a59 100644 --- a/src/XIVLauncher2.Common/XIVLauncher2.Common.csproj +++ b/src/XIVLauncher2.Common/XIVLauncher2.Common.csproj @@ -1,12 +1,13 @@ - + - net7.0 - enable - enable - true - + XIVLauncher2.Common + XIVLauncher2.Common + Common components for XIVLauncher2. + 2.0.0 + disable + + true - true true true @@ -25,22 +26,29 @@ $(DefineConstants);LINUX + + $(MSBuildProjectDirectory)\ + $(AppOutputBase)=C:\goatsoft\xl\XIVLauncher.Common\ + + + + Library + net7.0 + latest + true + true + - diff --git a/src/XIVLauncher2.sln b/src/XIVLauncher2.sln index 14fb6e5..8fa7613 100644 --- a/src/XIVLauncher2.sln +++ b/src/XIVLauncher2.sln @@ -2,7 +2,11 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XIVLauncher2", "XIVLauncher2\XIVLauncher2.csproj", "{92F61103-FC83-48D6-8678-B1DDD1B8BEEA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XIVLauncher2.Common", "XIVLauncher2.Common\XIVLauncher2.Common.csproj", "{C7DC3CBB-CFFA-4FFA-93CB-16ED892430EF}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XIVLauncher2.Common", "XIVLauncher2.Common\XIVLauncher2.Common.csproj", "{A19943FC-450A-4F99-AB0D-B43EB910EFB9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XIVLauncher2.Common.Unix", "XIVLauncher2.Common.Unix\XIVLauncher2.Common.Unix.csproj", "{74160ED3-9F5A-486A-9F1A-4B1F21BCE0AD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XIVLauncher2.Common.Windows", "XIVLauncher2.Common.Windows\XIVLauncher2.Common.Windows.csproj", "{A3DC19C7-CE2C-489F-9602-50E41DC4FEA7}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -14,9 +18,17 @@ Global {92F61103-FC83-48D6-8678-B1DDD1B8BEEA}.Debug|Any CPU.Build.0 = Debug|Any CPU {92F61103-FC83-48D6-8678-B1DDD1B8BEEA}.Release|Any CPU.ActiveCfg = Release|Any CPU {92F61103-FC83-48D6-8678-B1DDD1B8BEEA}.Release|Any CPU.Build.0 = Release|Any CPU - {C7DC3CBB-CFFA-4FFA-93CB-16ED892430EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C7DC3CBB-CFFA-4FFA-93CB-16ED892430EF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C7DC3CBB-CFFA-4FFA-93CB-16ED892430EF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C7DC3CBB-CFFA-4FFA-93CB-16ED892430EF}.Release|Any CPU.Build.0 = Release|Any CPU + {A19943FC-450A-4F99-AB0D-B43EB910EFB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A19943FC-450A-4F99-AB0D-B43EB910EFB9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A19943FC-450A-4F99-AB0D-B43EB910EFB9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A19943FC-450A-4F99-AB0D-B43EB910EFB9}.Release|Any CPU.Build.0 = Release|Any CPU + {74160ED3-9F5A-486A-9F1A-4B1F21BCE0AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74160ED3-9F5A-486A-9F1A-4B1F21BCE0AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74160ED3-9F5A-486A-9F1A-4B1F21BCE0AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74160ED3-9F5A-486A-9F1A-4B1F21BCE0AD}.Release|Any CPU.Build.0 = Release|Any CPU + {A3DC19C7-CE2C-489F-9602-50E41DC4FEA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3DC19C7-CE2C-489F-9602-50E41DC4FEA7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3DC19C7-CE2C-489F-9602-50E41DC4FEA7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A3DC19C7-CE2C-489F-9602-50E41DC4FEA7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/XIVLauncher2/XIVLauncher2.csproj b/src/XIVLauncher2/XIVLauncher2.csproj index d8891c6..93fd756 100644 --- a/src/XIVLauncher2/XIVLauncher2.csproj +++ b/src/XIVLauncher2/XIVLauncher2.csproj @@ -19,6 +19,7 @@ +