diff --git a/BNetInstaller/AgentApp.cs b/BNetInstaller/AgentApp.cs index ce36b5f..88ef343 100644 --- a/BNetInstaller/AgentApp.cs +++ b/BNetInstaller/AgentApp.cs @@ -1,9 +1,6 @@ -using System; -using System.ComponentModel; +using System.ComponentModel; using System.Diagnostics; -using System.IO; using BNetInstaller.Endpoints.Agent; -using BNetInstaller.Endpoints.Game; using BNetInstaller.Endpoints.Install; using BNetInstaller.Endpoints.Repair; using BNetInstaller.Endpoints.Update; @@ -11,42 +8,42 @@ namespace BNetInstaller; -internal class AgentApp : IDisposable +internal sealed class AgentApp : IDisposable { public readonly AgentEndpoint AgentEndpoint; public readonly InstallEndpoint InstallEndpoint; public readonly UpdateEndpoint UpdateEndpoint; public readonly RepairEndpoint RepairEndpoint; - public readonly GameEndpoint GameEndpoint; public readonly VersionEndpoint VersionEndpoint; - private readonly string AgentPath; - private readonly int Port = 5050; - - private Process Process; - private Requester Requester; + private readonly Process _process; + private readonly int _port; + private readonly AgentClient _client; public AgentApp() { - AgentPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Battle.net", "Agent", "Agent.exe"); - - if (!StartProcess()) + if (!StartProcess(out _process, out _port)) { - Console.WriteLine("Please ensure Battle.net is installed and has recently been opened."); + Console.WriteLine("Please ensure Battle.net is installed and has recently been signed in to."); Environment.Exit(0); } - AgentEndpoint = new(Requester); - InstallEndpoint = new(Requester); - UpdateEndpoint = new(Requester); - RepairEndpoint = new(Requester); - GameEndpoint = new(Requester); - VersionEndpoint = new(Requester); + _client = new(_port); + + AgentEndpoint = new(_client); + InstallEndpoint = new(_client); + UpdateEndpoint = new(_client); + RepairEndpoint = new(_client); + VersionEndpoint = new(_client); } - private bool StartProcess() + private static bool StartProcess(out Process process, out int port) { - if (!File.Exists(AgentPath)) + (process, port) = (null, -1); + + var agentPath = GetAgentPath(); + + if (!File.Exists(agentPath)) { Console.WriteLine("Unable to find Agent.exe."); return false; @@ -54,8 +51,25 @@ private bool StartProcess() try { - Process = Process.Start(AgentPath, $"--port={Port}"); - Requester = new Requester(Port); + process = Process.Start(new ProcessStartInfo(agentPath) + { + Arguments = "--internalclienttools", + UseShellExecute = true, + }); + + // detect listening port + while (process is { HasExited: false } && port == -1) + { + Thread.Sleep(250); + port = NativeMethods.GetProcessListeningPort(process.Id); + } + + if (process is not { HasExited: false } || port == -1) + { + Console.WriteLine("Unable to connect to Agent.exe."); + return false; + } + return true; } catch (Win32Exception) @@ -65,12 +79,26 @@ private bool StartProcess() } } + private static string GetAgentPath() + { + var agentDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Battle.net", "Agent"); + var parentPath = Path.Combine(agentDirectory, "Agent.exe"); + var parentVersion = 0; + + // read parent Agent.exe version + if (File.Exists(parentPath)) + parentVersion = FileVersionInfo.GetVersionInfo(parentPath).ProductPrivatePart; + + // return expected child Agent path + return Path.Combine(agentDirectory, $"Agent.{parentVersion}", "Agent.exe"); + } + public void Dispose() { - if (Process?.HasExited == false) - Process.Kill(); + if (_process?.HasExited == false) + _process.Kill(); - Requester?.Dispose(); - Process?.Dispose(); + _client?.Dispose(); + _process?.Dispose(); } } diff --git a/BNetInstaller/AgentClient.cs b/BNetInstaller/AgentClient.cs new file mode 100644 index 0000000..48dfa80 --- /dev/null +++ b/BNetInstaller/AgentClient.cs @@ -0,0 +1,93 @@ +using System.Diagnostics; + +namespace BNetInstaller; + +internal sealed class AgentClient : IDisposable +{ + private readonly HttpClient _client; + private readonly JsonSerializerOptions _serializerOptions; + + public AgentClient(int port) + { + _client = new(); + _client.DefaultRequestHeaders.Add("User-Agent", "phoenix-agent/1.0"); + _client.BaseAddress = new($"http://127.0.0.1:{port}"); + + _serializerOptions = new() + { + PropertyNamingPolicy = SnakeCaseNamingPolicy.Instance, + DictionaryKeyPolicy = SnakeCaseNamingPolicy.Instance, + }; + } + + public void SetAuthToken(string token) + { + _client.DefaultRequestHeaders.Add("Authorization", token); + } + + public async Task SendAsync(string endpoint, HttpMethod method, string content = null) + { + var request = new HttpRequestMessage(method, endpoint); + + if (!string.IsNullOrEmpty(content)) + request.Content = new StringContent(content); + + var response = await _client.SendAsync(request); + + if (!response.IsSuccessStatusCode) + await HandleRequestFailure(response, endpoint); + + return response; + } + + public async Task SendAsync(string endpoint, HttpMethod method, T payload = null) where T : class + { + if (payload == null) + return await SendAsync(endpoint, method); + else + return await SendAsync(endpoint, method, JsonSerializer.Serialize(payload, _serializerOptions)); + } + + private static async Task HandleRequestFailure(HttpResponseMessage response, string endpoint) + { + var statusCode = response.StatusCode; + var content = await response.Content.ReadAsStringAsync(); + Debug.WriteLine($"{(int)statusCode} {statusCode}: {endpoint} {content}"); + } + + public void Dispose() + { + _client.Dispose(); + } +} + +file class SnakeCaseNamingPolicy : JsonNamingPolicy +{ + public static readonly SnakeCaseNamingPolicy Instance = new(); + + public override string ConvertName(string name) + { + if (string.IsNullOrEmpty(name)) + return string.Empty; + + Span input = stackalloc char[0x100]; + Span output = stackalloc char[0x100]; + + // lowercase the name + var inputLen = name.AsSpan().ToLowerInvariant(input); + var outputLen = 0; + + for (var i = 0; i < inputLen; i++) + { + // prefix an underscore before any capitals + // excluding the initial character + if (name[i] is >= 'A' and <= 'Z' && i != 0) + output[outputLen++] = '_'; + + // write the lowercase character to the output + output[outputLen++] = input[i]; + } + + return new string(output[..outputLen]); + } +} diff --git a/BNetInstaller/BNetInstaller.csproj b/BNetInstaller/BNetInstaller.csproj index c975b7e..7cc0d40 100644 --- a/BNetInstaller/BNetInstaller.csproj +++ b/BNetInstaller/BNetInstaller.csproj @@ -2,12 +2,20 @@ Exe - net6.0 + net8.0 + enable + true + warnings - - + + + + + + + diff --git a/BNetInstaller/Constants/HttpVerb.cs b/BNetInstaller/Constants/HttpVerb.cs deleted file mode 100644 index 0034bf3..0000000 --- a/BNetInstaller/Constants/HttpVerb.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace BNetInstaller.Constants; - -internal enum HttpVerb -{ - GET, - PUT, - POST, - DELETE -} diff --git a/BNetInstaller/Constants/Locale.cs b/BNetInstaller/Constants/Locale.cs deleted file mode 100644 index c50fb74..0000000 --- a/BNetInstaller/Constants/Locale.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace BNetInstaller.Constants; - -internal enum Locale -{ - arSA, - enSA, - deDE, - enUS, - esMX, - ptBR, - esES, - frFR, - itIT, - koKR, - plPL, - ruRU, - zhCN, - zhTW -} diff --git a/BNetInstaller/Constants/Mode.cs b/BNetInstaller/Constants/Mode.cs deleted file mode 100644 index 67f6c48..0000000 --- a/BNetInstaller/Constants/Mode.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace BNetInstaller.Constants; - -public enum Mode -{ - Install, - Repair -} diff --git a/BNetInstaller/Endpoints/Agent/AgentEndpoint.cs b/BNetInstaller/Endpoints/Agent/AgentEndpoint.cs index e10d2fd..2f486ef 100644 --- a/BNetInstaller/Endpoints/Agent/AgentEndpoint.cs +++ b/BNetInstaller/Endpoints/Agent/AgentEndpoint.cs @@ -1,35 +1,16 @@ -using System; -using System.Threading.Tasks; -using BNetInstaller.Constants; -using Newtonsoft.Json.Linq; +namespace BNetInstaller.Endpoints.Agent; -namespace BNetInstaller.Endpoints.Agent; - -internal class AgentEndpoint : BaseEndpoint +internal sealed class AgentEndpoint(AgentClient client) : BaseEndpoint("agent", client) { - public AgentEndpoint(Requester requester) : base("agent", requester) - { - } - - public async Task Delete() + protected override void ValidateResponse(JsonNode response, string content) { - await Requester.SendAsync(Endpoint, HttpVerb.DELETE); - } - - public async Task Get() - { - using var response = await Requester.SendAsync(Endpoint, HttpVerb.GET); - return await Deserialize(response); - } + base.ValidateResponse(response, content); - protected override void ValidateResponse(JToken response, string content) - { - var token = response.Value("authorization"); + var token = response["authorization"]?.GetValue(); if (string.IsNullOrEmpty(token)) throw new Exception("Agent Error: Unable to authenticate.", new(content)); - Requester.SetAuthorization(token); - base.ValidateResponse(response, content); + Client.SetAuthToken(token); } } diff --git a/BNetInstaller/Endpoints/BaseEndpoint.cs b/BNetInstaller/Endpoints/BaseEndpoint.cs index 69aa236..0ec53fe 100644 --- a/BNetInstaller/Endpoints/BaseEndpoint.cs +++ b/BNetInstaller/Endpoints/BaseEndpoint.cs @@ -1,33 +1,52 @@ -using System; -using System.Net.Http; -using System.Threading.Tasks; -using Newtonsoft.Json.Linq; +namespace BNetInstaller.Endpoints; -namespace BNetInstaller.Endpoints; - -internal abstract class BaseEndpoint +internal abstract class BaseEndpoint(string endpoint, AgentClient client) where T : class, IModel, new() { - public string Endpoint { get; } + public string Endpoint { get; } = endpoint; + public T Model { get; } = new(); + + protected AgentClient Client { get; } = client; + + public virtual async Task Get() + { + using var response = await Client.SendAsync(Endpoint, HttpMethod.Get); + return await Deserialize(response); + } + + public virtual async Task Post() + { + if (Model is NullModel) + return default; + + using var response = await Client.SendAsync(Endpoint, HttpMethod.Post, Model); + return await Deserialize(response); + } - protected Requester Requester { get; } + public virtual async Task Put() + { + if (Model is NullModel) + return default; + + using var response = await Client.SendAsync(Endpoint, HttpMethod.Put, Model); + return await Deserialize(response); + } - protected BaseEndpoint(string endpoint, Requester requester) + public virtual async Task Delete() { - Endpoint = endpoint; - Requester = requester; + await Client.SendAsync(Endpoint, HttpMethod.Delete); } - protected async Task Deserialize(HttpResponseMessage response) + protected async Task Deserialize(HttpResponseMessage response) { var content = await response.Content.ReadAsStringAsync(); - var result = JToken.Parse(content); + var result = JsonNode.Parse(content); ValidateResponse(result, content); return result; } - protected virtual void ValidateResponse(JToken response, string content) + protected virtual void ValidateResponse(JsonNode response, string content) { - var errorCode = response.Value("error"); + var errorCode = response["error"]?.GetValue(); if (errorCode > 0) throw new Exception($"Agent Error: {errorCode}", new(content)); } diff --git a/BNetInstaller/Endpoints/BaseProductEndpoint.cs b/BNetInstaller/Endpoints/BaseProductEndpoint.cs new file mode 100644 index 0000000..eaa0783 --- /dev/null +++ b/BNetInstaller/Endpoints/BaseProductEndpoint.cs @@ -0,0 +1,13 @@ +namespace BNetInstaller.Endpoints; + +internal abstract class BaseProductEndpoint(string endpoint, AgentClient client) : BaseEndpoint(endpoint, client) where T : class, IModel, new() +{ + public ProductEndpoint Product { get; private set; } + + public override async Task Post() + { + var content = await base.Post(); + Product = ProductEndpoint.CreateFromResponse(content, Client); + return content; + } +} diff --git a/BNetInstaller/Endpoints/Game/GameEndpoint.cs b/BNetInstaller/Endpoints/Game/GameEndpoint.cs deleted file mode 100644 index 9eb6f14..0000000 --- a/BNetInstaller/Endpoints/Game/GameEndpoint.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Threading.Tasks; -using BNetInstaller.Constants; -using Newtonsoft.Json.Linq; - -namespace BNetInstaller.Endpoints.Game; - -internal class GameEndpoint : BaseEndpoint -{ - public GameEndpoint(Requester requester) : base("game", requester) - { - } - - public async Task Get(string uid) - { - using var response = await Requester.SendAsync(Endpoint + "/" + uid, HttpVerb.GET); - return await Deserialize(response); - } -} diff --git a/BNetInstaller/Endpoints/Install/InstallEndpoint.cs b/BNetInstaller/Endpoints/Install/InstallEndpoint.cs index 864a0b5..341b01c 100644 --- a/BNetInstaller/Endpoints/Install/InstallEndpoint.cs +++ b/BNetInstaller/Endpoints/Install/InstallEndpoint.cs @@ -1,53 +1,25 @@ -using System; -using System.Threading.Tasks; -using BNetInstaller.Constants; -using BNetInstaller.Models; -using Newtonsoft.Json.Linq; +namespace BNetInstaller.Endpoints.Install; -namespace BNetInstaller.Endpoints.Install; - -internal class InstallEndpoint : BaseEndpoint +internal sealed class InstallEndpoint(AgentClient client) : BaseProductEndpoint("install", client) { - public InstallModel Model { get; } - public ProductEndpoint Product { get; private set; } - - public InstallEndpoint(Requester requester) : base("install", requester) - { - Model = new(); - } - - public async Task Post() + protected override void ValidateResponse(JsonNode response, string content) { - using var response = await Requester.SendAsync(Endpoint, HttpVerb.POST, Model); - var content = await Deserialize(response); - Product = ProductEndpoint.CreateFromResponse(content, Requester); - return content; - } + var agentError = response["error"]?.GetValue(); - protected override void ValidateResponse(JToken response, string content) - { - var agentError = response.Value("error"); + if (agentError.GetValueOrDefault() <= 0) + return; - if (agentError > 0) + // try to identify the erroneous section + foreach (var section in new[] { "authentication", "game_dir", "min_spec" }) { - // try to identify the erroneous section - foreach (var section in SubSections) - { - var token = response["form"]?[section]; - var errorCode = token?.Value("error"); - if (errorCode > 0) - throw new Exception($"Agent Error: Unable to install - {errorCode} ({section}).", new(content)); - } + var node = response["form"]?[section]; + var errorCode = node?["error"]?.GetValue(); - // fallback to throwing a global error - throw new Exception($"Agent Error: {agentError}", new(content)); + if (errorCode > 0) + throw new Exception($"Agent Error: Unable to install - {errorCode} ({section}).", new(content)); } - } - private static readonly string[] SubSections = new[] - { - "authentication", - "game_dir", - "min_spec" - }; + // fallback to throwing a global error + throw new Exception($"Agent Error: {agentError}", new(content)); + } } diff --git a/BNetInstaller/Endpoints/ProductEndpoint.cs b/BNetInstaller/Endpoints/ProductEndpoint.cs index 8c7bb15..955881d 100644 --- a/BNetInstaller/Endpoints/ProductEndpoint.cs +++ b/BNetInstaller/Endpoints/ProductEndpoint.cs @@ -1,38 +1,14 @@ -using System.Threading.Tasks; -using BNetInstaller.Constants; -using BNetInstaller.Models; -using Newtonsoft.Json.Linq; +namespace BNetInstaller.Endpoints; -namespace BNetInstaller.Endpoints; - -internal class ProductEndpoint : BaseEndpoint +internal sealed class ProductEndpoint(string endpoint, AgentClient client) : BaseEndpoint(endpoint, client) { - public ProductModel Model { get; } - - public ProductEndpoint(string endpoint, Requester requester) : base(endpoint, requester) - { - Model = new(); - } - - public async Task Get() - { - using var response = await Requester.SendAsync(Endpoint, HttpVerb.GET); - return await Deserialize(response); - } - - public async Task Post() - { - using var response = await Requester.SendAsync(Endpoint, HttpVerb.POST, Model); - return await Deserialize(response); - } - - public static ProductEndpoint CreateFromResponse(JToken content, Requester requester) + public static ProductEndpoint CreateFromResponse(JsonNode content, AgentClient client) { - var responseURI = content.Value("response_uri"); + var responseURI = content["response_uri"]?.GetValue(); - if (!string.IsNullOrEmpty(responseURI)) - return new(responseURI.TrimStart('/'), requester); + if (string.IsNullOrEmpty(responseURI)) + return null; - return null; + return new(responseURI.TrimStart('/'), client); } } diff --git a/BNetInstaller/Endpoints/Repair/RepairEndpoint.cs b/BNetInstaller/Endpoints/Repair/RepairEndpoint.cs index d14c930..e513ddc 100644 --- a/BNetInstaller/Endpoints/Repair/RepairEndpoint.cs +++ b/BNetInstaller/Endpoints/Repair/RepairEndpoint.cs @@ -1,32 +1,9 @@ -using System.Threading.Tasks; -using BNetInstaller.Constants; -using BNetInstaller.Models; -using Newtonsoft.Json.Linq; +namespace BNetInstaller.Endpoints.Repair; -namespace BNetInstaller.Endpoints.Repair; - -internal class RepairEndpoint : BaseEndpoint +internal sealed class RepairEndpoint : BaseProductEndpoint { - public ProductPriorityModel Model { get; } - public ProductEndpoint Product { get; private set; } - - public RepairEndpoint(Requester requester) : base("repair", requester) + public RepairEndpoint(AgentClient client) : base("repair", client) { - Model = new(); Model.Priority.Value = 1000; } - - public async Task Get() - { - using var response = await Requester.SendAsync(Endpoint, HttpVerb.GET); - return await Deserialize(response); - } - - public async Task Post() - { - using var response = await Requester.SendAsync(Endpoint, HttpVerb.POST, Model); - var content = await Deserialize(response); - Product = ProductEndpoint.CreateFromResponse(content, Requester); - return content; - } } diff --git a/BNetInstaller/Endpoints/Update/UpdateEndpoint.cs b/BNetInstaller/Endpoints/Update/UpdateEndpoint.cs index 4ea0e8f..3c6d7a6 100644 --- a/BNetInstaller/Endpoints/Update/UpdateEndpoint.cs +++ b/BNetInstaller/Endpoints/Update/UpdateEndpoint.cs @@ -1,32 +1,9 @@ -using System.Threading.Tasks; -using BNetInstaller.Constants; -using BNetInstaller.Models; -using Newtonsoft.Json.Linq; +namespace BNetInstaller.Endpoints.Update; -namespace BNetInstaller.Endpoints.Update; - -internal class UpdateEndpoint : BaseEndpoint +internal sealed class UpdateEndpoint : BaseProductEndpoint { - public ProductPriorityModel Model { get; } - public ProductEndpoint Product { get; private set; } - - public UpdateEndpoint(Requester requester) : base("update", requester) + public UpdateEndpoint(AgentClient client) : base("update", client) { - Model = new(); Model.Priority.Value = 699; } - - public async Task Get() - { - using var response = await Requester.SendAsync(Endpoint, HttpVerb.GET); - return await Deserialize(response); - } - - public async Task Post() - { - using var response = await Requester.SendAsync(Endpoint, HttpVerb.POST, Model); - var content = await Deserialize(response); - Product = ProductEndpoint.CreateFromResponse(content, Requester); - return content; - } } diff --git a/BNetInstaller/Endpoints/Version/VersionEndpoint.cs b/BNetInstaller/Endpoints/Version/VersionEndpoint.cs index 7f83083..3ca625b 100644 --- a/BNetInstaller/Endpoints/Version/VersionEndpoint.cs +++ b/BNetInstaller/Endpoints/Version/VersionEndpoint.cs @@ -1,28 +1,10 @@ -using System.Threading.Tasks; -using BNetInstaller.Constants; -using BNetInstaller.Models; -using Newtonsoft.Json.Linq; +namespace BNetInstaller.Endpoints.Version; -namespace BNetInstaller.Endpoints.Version; - -internal class VersionEndpoint : BaseEndpoint +internal sealed class VersionEndpoint(AgentClient client) : BaseEndpoint("version", client) { - public UidModel Model { get; } - - public VersionEndpoint(Requester requester) : base("version", requester) - { - Model = new(); - } - - public async Task Get() - { - using var response = await Requester.SendAsync(Endpoint + "/" + Model.Uid, HttpVerb.GET); - return await Deserialize(response); - } - - public async Task Post() + public override async Task Get() { - using var response = await Requester.SendAsync(Endpoint, HttpVerb.POST, Model); + using var response = await Client.SendAsync(Endpoint + "/" + Model.Uid, HttpMethod.Get); return await Deserialize(response); } } diff --git a/BNetInstaller/Models/IModel.cs b/BNetInstaller/Models/IModel.cs new file mode 100644 index 0000000..f183bd3 --- /dev/null +++ b/BNetInstaller/Models/IModel.cs @@ -0,0 +1,5 @@ +namespace BNetInstaller.Models; + +internal interface IModel +{ +} diff --git a/BNetInstaller/Models/InstallModel.cs b/BNetInstaller/Models/InstallModel.cs index 62dae53..31000e5 100644 --- a/BNetInstaller/Models/InstallModel.cs +++ b/BNetInstaller/Models/InstallModel.cs @@ -1,8 +1,7 @@ namespace BNetInstaller.Models; -internal class InstallModel : ProductPriorityModel +internal sealed class InstallModel : ProductPriorityModel { - public string[] InstructionsDataset { get; set; } public string InstructionsPatchUrl { get; set; } public string InstructionsProduct { get; set; } = "NGDP"; public double MonitorPid { get; set; } = 12345; diff --git a/BNetInstaller/Models/NullModel.cs b/BNetInstaller/Models/NullModel.cs new file mode 100644 index 0000000..59c9106 --- /dev/null +++ b/BNetInstaller/Models/NullModel.cs @@ -0,0 +1,5 @@ +namespace BNetInstaller.Models; + +internal sealed class NullModel : IModel +{ +} diff --git a/BNetInstaller/Models/PriorityModel.cs b/BNetInstaller/Models/PriorityModel.cs index b294c26..36500a0 100644 --- a/BNetInstaller/Models/PriorityModel.cs +++ b/BNetInstaller/Models/PriorityModel.cs @@ -1,6 +1,6 @@ namespace BNetInstaller.Models; -internal class PriorityModel +internal sealed class PriorityModel : IModel { public bool InsertAtHead { get; set; } = true; public double Value { get; set; } = 900; diff --git a/BNetInstaller/Models/ProductModel.cs b/BNetInstaller/Models/ProductModel.cs index 00a7e65..71a9636 100644 --- a/BNetInstaller/Models/ProductModel.cs +++ b/BNetInstaller/Models/ProductModel.cs @@ -1,12 +1,12 @@ namespace BNetInstaller.Models; -internal class ProductModel +internal sealed class ProductModel : IModel { public string AccountCountry { get; set; } = "USA"; public bool Finalized { get; set; } = true; public string GameDir { get; set; } public string GeoIpCountry { get; set; } = "US"; - public string[] Language { get; set; } = new[] { "enus" }; + public string[] Language { get; set; } = ["enus"]; public string SelectedAssetLocale { get; set; } = "enus"; public string SelectedLocale { get; set; } = "enus"; public string Shortcut { get; set; } = "all"; diff --git a/BNetInstaller/Models/UidModel.cs b/BNetInstaller/Models/UidModel.cs index e86479f..074938d 100644 --- a/BNetInstaller/Models/UidModel.cs +++ b/BNetInstaller/Models/UidModel.cs @@ -1,6 +1,6 @@ namespace BNetInstaller.Models; -internal class UidModel +internal class UidModel : IModel { public string Uid { get; set; } } diff --git a/BNetInstaller/NativeMethods.cs b/BNetInstaller/NativeMethods.cs new file mode 100644 index 0000000..1d061d3 --- /dev/null +++ b/BNetInstaller/NativeMethods.cs @@ -0,0 +1,63 @@ +using System.Buffers.Binary; +using System.Runtime.InteropServices; + +namespace BNetInstaller; + +internal static partial class NativeMethods +{ + private const int MIB_TCPROW2_SIZE = 0x1C; + private const int MIB_TCP_STATE_LISTEN = 2; + + [LibraryImport("iphlpapi.dll", EntryPoint = "GetTcpTable2")] + private static partial int GetTcpTable(nint tcpTable, ref int size, [MarshalAs(UnmanagedType.Bool)] bool bOrder); + + public static int GetProcessListeningPort(int pid) + { + var size = 0; + + // get MIB_TCPTABLE2 size + _ = GetTcpTable(0, ref size, false); + + var buffer = Marshal.AllocHGlobal(size); + var pBuffer = buffer; + + try + { + // read MIB_TCPTABLE2 + if (GetTcpTable(pBuffer, ref size, false) != 0) + return -1; + + var dwNumEntries = Marshal.ReadInt32(pBuffer); // MIB_TCPTABLE2->dwNumEntries + pBuffer += sizeof(int); + + for (var i = 0; i < dwNumEntries; i++) + { + var row = Marshal.PtrToStructure(pBuffer); + pBuffer += MIB_TCPROW2_SIZE; + + if (row.dwOwningPid == pid && row.dwState == MIB_TCP_STATE_LISTEN) + { + return BinaryPrimitives.ReverseEndianness((ushort)row.dwLocalPort); + } + } + } + finally + { + Marshal.FreeHGlobal(buffer); + } + + return -1; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MIB_TCPROW2 + { + public int dwState; + public int dwLocalAddr; + public int dwLocalPort; + public int dwRemoteAddr; + public int dwRemotePort; + public int dwOwningPid; + public int dwOffloadState; + } +} diff --git a/BNetInstaller/Operations/AgentTask.cs b/BNetInstaller/Operations/AgentTask.cs new file mode 100644 index 0000000..6e0c5f6 --- /dev/null +++ b/BNetInstaller/Operations/AgentTask.cs @@ -0,0 +1,68 @@ +using System.Runtime.CompilerServices; +using BNetInstaller.Endpoints; + +namespace BNetInstaller.Operations; + +internal abstract class AgentTask(Options options) +{ + private readonly Options _options = options; + private TaskAwaiter? _awaiter; + + public TaskAwaiter GetAwaiter() => _awaiter ??= InnerTask().GetAwaiter(); + + public T GetResult() => GetAwaiter().GetResult(); + + protected abstract Task InnerTask(); + + protected async Task PrintProgress(ProductEndpoint endpoint) + { + var locale = _options.Locale.ToString(); + var cursor = (Left: 0, Top: 0); + + if (_options.Verbose) + cursor = Console.GetCursorPosition(); + + static void Print(string label, object value) => + Console.WriteLine("{0,-20}{1,-20}", label, value); + + while (true) + { + var stats = await endpoint.Get(); + + // check for completion + var complete = stats["download_complete"]?.GetValue(); + + if (complete == true) + return true; + + // get progress percentage and playability + var progress = stats["progress"]?.GetValue(); + var playable = stats["playable"]?.GetValue(); + + if (!progress.HasValue) + return false; + + // some non-console environments don't support + // cursor positioning or line rewriting + if (_options.Verbose) + { + Console.SetCursorPosition(cursor.Left, cursor.Top); + Print("Downloading:", _options.Product); + Print("Language:", locale); + Print("Directory:", _options.Directory); + Print("Progress:", progress.Value.ToString("P4")); + Print("Playable:", playable.GetValueOrDefault()); + } + else + { + Print("Progress:", progress.Value.ToString("P4")); + } + + await Task.Delay(2000); + + // exit @ 100% + if (progress == 1f) + return true; + } + } +} diff --git a/BNetInstaller/Operations/InstallProductTask.cs b/BNetInstaller/Operations/InstallProductTask.cs new file mode 100644 index 0000000..c5107d9 --- /dev/null +++ b/BNetInstaller/Operations/InstallProductTask.cs @@ -0,0 +1,27 @@ +namespace BNetInstaller.Operations; + +internal sealed class InstallProductTask(Options options, AgentApp app) : AgentTask(options) +{ + private readonly Options _options = options; + private readonly AgentApp _app = app; + + protected override async Task InnerTask() + { + // initiate the download + _app.UpdateEndpoint.Model.Uid = _options.UID; + await _app.UpdateEndpoint.Post(); + + // first try the install endpoint + if (await PrintProgress(_app.InstallEndpoint.Product)) + return true; + + // then try the update endpoint instead + if (await PrintProgress(_app.UpdateEndpoint.Product)) + return true; + + // failing that another agent or the BNet app has + // probably taken control of the install + Console.WriteLine("Another application has taken over. Launch the Battle.Net app to resume installation."); + return false; + } +} diff --git a/BNetInstaller/Operations/RepairProductTask.cs b/BNetInstaller/Operations/RepairProductTask.cs new file mode 100644 index 0000000..f6d38f6 --- /dev/null +++ b/BNetInstaller/Operations/RepairProductTask.cs @@ -0,0 +1,21 @@ +namespace BNetInstaller.Operations; + +internal sealed class RepairProductTask(Options options, AgentApp app) : AgentTask(options) +{ + private readonly Options _options = options; + private readonly AgentApp _app = app; + + protected override async Task InnerTask() + { + // initiate the repair + _app.RepairEndpoint.Model.Uid = _options.UID; + await _app.RepairEndpoint.Post(); + + // run the repair endpoint + if (await PrintProgress(_app.RepairEndpoint.Product)) + return true; + + Console.WriteLine("Unable to repair this product."); + return false; + } +} diff --git a/BNetInstaller/Options.cs b/BNetInstaller/Options.cs index 31d3e86..dffb89d 100644 --- a/BNetInstaller/Options.cs +++ b/BNetInstaller/Options.cs @@ -1,26 +1,17 @@ -using System; +using System.CommandLine; using System.Text.RegularExpressions; -using BNetInstaller.Constants; -using CommandLine; namespace BNetInstaller; -internal class Options +internal sealed partial class Options { - [Option("prod", Required = true, HelpText = "TACT Product")] public string Product { get; set; } - - [Option("lang", Required = true, HelpText = "Game/Asset language")] public Locale Locale { get; set; } - - [Option("dir", Required = true, HelpText = "Installation Directory")] public string Directory { get; set; } - - [Option("uid", HelpText = "Agent Product UID (Required if different to the TACT product)")] public string UID { get; set; } - - [Option("repair", HelpText = "Run installation repair")] public bool Repair { get; set; } + public bool Verbose { get; set; } + public string PostDownload { get; set; } public void Sanitise() { @@ -30,19 +21,65 @@ public void Sanitise() // remove _locale suffix for wiki copy-pasters if (UID.Contains("_locale", StringComparison.OrdinalIgnoreCase)) - UID = Regex.Replace(UID, "\\(?_locale\\)?", $"_{Locale}", RegexOptions.IgnoreCase); + UID = ExtractLocaleRegex().Replace(UID, $"_{Locale}"); Product = Product.ToLowerInvariant().Trim(); UID = UID.ToLowerInvariant().Trim(); - Directory = Directory.Replace("/", "\\").Trim().TrimEnd('\\') + '\\'; + Directory = Path.GetFullPath(Directory + "\\"); } - public static string[] Generate() + [GeneratedRegex("\\(?_locale\\)?", RegexOptions.IgnoreCase)] + private static partial Regex ExtractLocaleRegex(); +} + +internal static class OptionsBinder +{ + private static readonly Option Product = new("--prod") + { + HelpName = "TACT Product", + Required = true + }; + + private static readonly Option Locale = new("--lang") + { + HelpName = "Game/Asset language", + Required = true + }; + + private static readonly Option Directory = new("--dir") + { + HelpName = "Installation Directory", + Required = true + }; + + private static readonly Option UID = new("--uid") + { + HelpName = "Agent Product UID (Required if different to the TACT product)", + Required = true + }; + + private static readonly Option Repair = new("--repair") + { + HelpName = "Run installation repair" + }; + + private static readonly Option Verbose = new("--verbose") + { + HelpName = "Enables/disables verbose progress reporting", + DefaultValueFactory = (_) => true + }; + + private static readonly Option PostDownload = new("--post-download") + { + HelpName = "Specifies a file or app to run on completion" + }; + + public static string[] CreateArgs() { static string GetInput(string message) { Console.Write(message); - return Console.ReadLine().Trim().Trim('"'); + return Console.ReadLine()?.Trim().Trim('"'); } Console.WriteLine("Please complete the following information:"); @@ -63,9 +100,59 @@ static string GetInput(string message) Console.WriteLine(); // fix repair arg - if (args[8] != "" && args[8][0] == 'Y') + if (args[8] is ['Y', ..]) args[8] = "--repair"; return args; } + + public static RootCommand BuildRootCommand(Func task) + { + var rootCommand = new RootCommand() + { + Product, + Locale, + Directory, + UID, + Repair, + Verbose, + PostDownload + }; + + rootCommand.SetAction(async context => + { + await task(new() + { + Product = context.CommandResult.GetValue(Product), + Locale = context.CommandResult.GetValue(Locale), + Directory = context.CommandResult.GetValue(Directory), + UID = context.CommandResult.GetValue(UID), + Repair = context.CommandResult.GetValue(Repair), + Verbose = context.CommandResult.GetValue(Verbose), + PostDownload = context.CommandResult.GetValue(PostDownload), + }); + }); + + rootCommand.TreatUnmatchedTokensAsErrors = false; + + return rootCommand; + } +} + +internal enum Locale +{ + arSA, + enSA, + deDE, + enUS, + esMX, + ptBR, + esES, + frFR, + itIT, + koKR, + plPL, + ruRU, + zhCN, + zhTW } diff --git a/BNetInstaller/Program.cs b/BNetInstaller/Program.cs index 5e9ef55..a74d001 100644 --- a/BNetInstaller/Program.cs +++ b/BNetInstaller/Program.cs @@ -1,8 +1,5 @@ -using System; -using System.Threading.Tasks; -using BNetInstaller.Constants; -using BNetInstaller.Endpoints; -using CommandLine; +using System.Diagnostics; +using BNetInstaller.Operations; namespace BNetInstaller; @@ -10,19 +7,13 @@ internal static class Program { private static async Task Main(string[] args) { - if (args == null || args.Length == 0) - args = Options.Generate(); + if (args is not { Length: > 0 }) + args = OptionsBinder.CreateArgs(); - using Parser parser = new(s => - { - s.HelpWriter = Console.Error; - s.CaseInsensitiveEnumValues = true; - s.AutoVersion = false; - }); - - await parser - .ParseArguments(args) - .MapResult(Run, Task.FromResult); + await OptionsBinder + .BuildRootCommand(Run) + .Parse(args) + .InvokeAsync(); } private static async Task Run(Options options) @@ -37,101 +28,37 @@ private static async Task Run(Options options) await app.AgentEndpoint.Get(); Console.WriteLine($"Queuing {mode}"); - app.InstallEndpoint.Model.InstructionsDataset = new[] { "torrent", "win", options.Product, locale.ToLowerInvariant() }; app.InstallEndpoint.Model.InstructionsPatchUrl = $"http://us.patch.battle.net:1119/{options.Product}"; app.InstallEndpoint.Model.Uid = options.UID; await app.InstallEndpoint.Post(); Console.WriteLine($"Starting {mode}"); app.InstallEndpoint.Product.Model.GameDir = options.Directory; - app.InstallEndpoint.Product.Model.Language[0] = locale; - app.InstallEndpoint.Product.Model.SelectedAssetLocale = locale; - app.InstallEndpoint.Product.Model.SelectedLocale = locale; await app.InstallEndpoint.Product.Post(); Console.WriteLine(); - var operation = mode switch + AgentTask operation = mode switch { - Mode.Install => InstallProduct(options, app), - Mode.Repair => RepairProduct(options, app), + Mode.Install => new InstallProductTask(options, app), + Mode.Repair => new RepairProductTask(options, app), _ => throw new NotSupportedException(), }; // process the task - await operation; + var complete = await operation; // send close signal await app.AgentEndpoint.Delete(); - } - - private static async Task InstallProduct(Options options, AgentApp app) - { - // initiate download - app.UpdateEndpoint.Model.Uid = options.UID; - await app.UpdateEndpoint.Post(); - - // first try install endpoint - if (await ProgressLoop(options, app.InstallEndpoint.Product)) - return; - - // then try the update endpoint instead - if (await ProgressLoop(options, app.UpdateEndpoint.Product)) - return; - // failing that another agent or the BNet app has probably taken control of the install - Console.WriteLine("Another application has taken over. Launch the Battle.Net app to resume installation."); + // run the post download app/script if applicable + if (complete && File.Exists(options.PostDownload)) + Process.Start(options.PostDownload); } +} - private static async Task RepairProduct(Options options, AgentApp app) - { - // initiate repair - app.RepairEndpoint.Model.Uid = options.UID; - await app.RepairEndpoint.Post(); - - // run the repair endpoint - if (await ProgressLoop(options, app.RepairEndpoint.Product)) - return; - - Console.WriteLine("Unable to repair this product."); - } - - private static async Task ProgressLoop(Options options, ProductEndpoint endpoint) - { - var locale = options.Locale.ToString(); - var cursorLeft = Console.CursorLeft; - var cursorTop = Console.CursorTop; - - static void Print(string label, object value) => - Console.WriteLine("{0,-20}{1,-20}", label, value); - - while (true) - { - var stats = await endpoint.Get(); - - // check for completion - var complete = stats.Value("download_complete"); - if (complete == true) - return true; - - // get progress percentage and playability - var progress = stats.Value("progress"); - var playable = stats.Value("playable"); - - if (!progress.HasValue) - return false; - - Console.SetCursorPosition(cursorLeft, cursorTop); - Print("Downloading:", options.Product); - Print("Language:", locale); - Print("Directory:", options.Directory); - Print("Progress:", progress.Value.ToString("P4")); - Print("Playable:", playable.GetValueOrDefault()); - await Task.Delay(2000); - - // exit @ 100% - if (progress == 1f) - return true; - } - } +file enum Mode +{ + Install, + Repair } diff --git a/BNetInstaller/Properties/PublishProfiles/FolderProfile.pubxml b/BNetInstaller/Properties/PublishProfiles/FolderProfile.pubxml index 922c241..b680ddb 100644 --- a/BNetInstaller/Properties/PublishProfiles/FolderProfile.pubxml +++ b/BNetInstaller/Properties/PublishProfiles/FolderProfile.pubxml @@ -7,12 +7,13 @@ https://go.microsoft.com/fwlink/?LinkID=208121. FileSystem Release Any CPU - net6.0 - bin\Release\net6.0\publish\ + net8.0 + bin\Release\net8.0\publish\ win-x64 true - True - False - True + true + false + true + true \ No newline at end of file diff --git a/BNetInstaller/Requester.cs b/BNetInstaller/Requester.cs deleted file mode 100644 index 0fd2ee4..0000000 --- a/BNetInstaller/Requester.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Diagnostics; -using System.Net.Http; -using System.Threading.Tasks; -using BNetInstaller.Constants; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; - -namespace BNetInstaller; - -internal class Requester : IDisposable -{ - public string BaseAddress { get; } - - private readonly HttpClient _client; - private readonly JsonSerializerSettings _serializerSettings; - - public Requester(int port) - { - BaseAddress = $"http://127.0.0.1:{port}/{{0}}"; - - _client = new(); - _client.DefaultRequestHeaders.Add("User-Agent", "phoenix-agent/1.0"); - - _serializerSettings = new() - { - ContractResolver = new DefaultContractResolver() - { - NamingStrategy = new SnakeCaseNamingStrategy() - } - }; - } - - public void SetAuthorization(string authorization) - { - _client.DefaultRequestHeaders.Add("Authorization", authorization); - } - - public async Task SendAsync(string endpoint, HttpVerb verb, string content = null) - { - var url = string.Format(BaseAddress, endpoint); - var request = new HttpRequestMessage(new(verb.ToString()), url); - - if (verb != HttpVerb.GET && !string.IsNullOrEmpty(content)) - request.Content = new StringContent(content); - - var response = await _client.SendAsync(request); - if (!response.IsSuccessStatusCode) - await HandleRequestFailure(response); - - return response; - } - - public async Task SendAsync(string endpoint, HttpVerb verb, T payload = null) where T : class - { - var content = payload != null ? - JsonConvert.SerializeObject(payload, _serializerSettings) : - null; - - return await SendAsync(endpoint, verb, content); - } - - private static async Task HandleRequestFailure(HttpResponseMessage response) - { - var uri = response.RequestMessage.RequestUri.AbsolutePath; - var statusCode = response.StatusCode; - var content = await response.Content.ReadAsStringAsync(); - Debug.WriteLine($"{(int)statusCode} {statusCode}: {uri} {content}"); - } - - public void Dispose() - { - _client.Dispose(); - } -} diff --git a/README.md b/README.md index 287f5cd..3bae739 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A tool for installing, updating and repairing games via Blizzard's Battle.net ap Windows only. See [releases](https://github.com/barncastle/Battle.Net-Installer/releases) for a compiled binary. #### Project Prerequisites -- [.NET 6.0](https://dotnet.microsoft.com/download/dotnet) +- [.NET 8.0](https://dotnet.microsoft.com/download/dotnet) - [Battle.net](https://www.blizzard.com/en-us/apps/battle.net/desktop) must be installed, up to date and have been recently signed in to. #### Arguments @@ -16,6 +16,8 @@ Windows only. See [releases](https://github.com/barncastle/Battle.Net-Installer/ | --dir | Installation Directory **(Required)** | | --uid | Agent UID (Required if different to the TACT Product) | | --repair | Repairs the installation opposed to installing/updating it | +| --verbose | Enables/disables verbose progress reporting | +| --post-download | Specifies a file or app to run on completion | | --help | Shows this table | - All TACT Products and Agent UIDs can be found [here](https://wowdev.wiki/TACT#Products) however only (green) Active products will work.