Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion BNetInstaller/AgentClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text;

namespace BNetInstaller;

Expand All @@ -11,6 +13,7 @@ public AgentClient(int port)
{
_client = new();
_client.DefaultRequestHeaders.Add("User-Agent", "phoenix-agent/1.0");
_client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_client.BaseAddress = new($"http://127.0.0.1:{port}");

_serializerOptions = new()
Expand All @@ -30,7 +33,7 @@ public async Task<HttpResponseMessage> SendAsync(string endpoint, HttpMethod met
var request = new HttpRequestMessage(method, endpoint);

if (!string.IsNullOrEmpty(content))
request.Content = new StringContent(content);
request.Content = new StringContent(content, Encoding.UTF8, "application/json");

var response = await _client.SendAsync(request);

Expand Down
24 changes: 24 additions & 0 deletions BNetInstaller/AgentException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;

namespace BNetInstaller;

internal sealed class AgentException : Exception
{
public int ErrorCode { get; }
public string? ResponseContent { get; }

public AgentException(int errorCode, string message, string? responseContent = null, Exception? inner = null)
: base(message, inner)
{
ErrorCode = errorCode;
ResponseContent = responseContent;
}

public static string Describe(int errorCode) => errorCode switch
{
2221 => "The supplied TACT Product is unavailable or invalid.",
2421 => "Your computer doesn't meet the minimum specs and/or space requirements.",
3001 => "The supplied TACT Product requires an encryption key which is missing.",
_ => "The Battle.net Agent returned an error."
};
}
75 changes: 66 additions & 9 deletions BNetInstaller/Endpoints/BaseEndpoint.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
namespace BNetInstaller.Endpoints;
using System;
using System.Text;
using System.Text.Json.Nodes;

namespace BNetInstaller.Endpoints;

internal abstract class BaseEndpoint<T>(string endpoint, AgentClient client) where T : class, IModel, new()
{
Expand All @@ -16,7 +20,7 @@ public virtual async Task<JsonNode> Get()
public virtual async Task<JsonNode> Post()
{
if (Model is NullModel)
return default;
return default!;

using var response = await Client.SendAsync(Endpoint, HttpMethod.Post, Model);
return await Deserialize(response);
Expand All @@ -25,7 +29,7 @@ public virtual async Task<JsonNode> Post()
public virtual async Task<JsonNode> Put()
{
if (Model is NullModel)
return default;
return default!;

using var response = await Client.SendAsync(Endpoint, HttpMethod.Put, Model);
return await Deserialize(response);
Expand All @@ -39,15 +43,68 @@ public virtual async Task Delete()
protected async Task<JsonNode> Deserialize(HttpResponseMessage response)
{
var content = await response.Content.ReadAsStringAsync();
var result = JsonNode.Parse(content);
ValidateResponse(result, content);
return result;

JsonNode? result = null;
try
{
result = JsonNode.Parse(content);
}
catch
{
// if bnet gives non-JSON then use the raw payload instead
throw new Exception("Agent returned a non-JSON response.", new Exception(content));
}

ValidateResponse(result!, content);
return result!;
}

private static int GetErrorCode(JsonNode? node)
{
try
{
var v = node?.GetValue<double?>();
return (int)Math.Round(v.GetValueOrDefault());
}
catch
{
return 0;
}
}

protected virtual void ValidateResponse(JsonNode response, string content)
{
var errorCode = response["error"]?.GetValue<float?>();
if (errorCode > 0)
throw new Exception($"Agent Error: {errorCode}", new(content));
var agentError = GetErrorCode(response?["error"]);

if (agentError <= 0)
return;

// see if bnet agent gives an error with the form payload
string? section = null;
int sectionError = 0;

if (response?["form"] is JsonObject form)
{
foreach (var kvp in form)
{
var node = kvp.Value;
var code = GetErrorCode(node?["error"]);
if (code > 0)
{
section = kvp.Key;
sectionError = code;
break;
}
}
}

var sb = new StringBuilder();
sb.Append($"Agent Error: {agentError} - {AgentException.Describe(agentError)}");

if (!string.IsNullOrWhiteSpace(section))
sb.Append($" (section: {section}, section error: {sectionError})");

// keep raw json (debug)
throw new AgentException(agentError, sb.ToString(), content, new Exception(content));
}
}
6 changes: 4 additions & 2 deletions BNetInstaller/Models/InstallModel.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
namespace BNetInstaller.Models;
using System;

namespace BNetInstaller.Models;

internal sealed class InstallModel : ProductPriorityModel
{
public string InstructionsPatchUrl { get; set; }
public string InstructionsProduct { get; set; } = "NGDP";
public double MonitorPid { get; set; } = 12345;
public double MonitorPid { get; set; } = Environment.ProcessId;
}
2 changes: 1 addition & 1 deletion BNetInstaller/Operations/AgentTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ internal abstract class AgentTask<T>(Options options)

protected async Task<bool> PrintProgress(ProductEndpoint endpoint)
{
var locale = _options.Locale.ToString();
var locale = _options.Locale.ToString().ToLowerInvariant();
var cursor = (Left: 0, Top: 0);

if (_options.Verbose)
Expand Down
26 changes: 20 additions & 6 deletions BNetInstaller/Operations/InstallProductTask.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace BNetInstaller.Operations;
namespace BNetInstaller.Operations;

internal sealed class InstallProductTask(Options options, AgentApp app) : AgentTask<bool>(options)
{
Expand All @@ -7,17 +7,31 @@ internal sealed class InstallProductTask(Options options, AgentApp app) : AgentT

protected override async Task<bool> InnerTask()
{
// initiate the download
_app.UpdateEndpoint.Model.Uid = _options.UID;
await _app.UpdateEndpoint.Post();
if (await PrintProgress(_app.InstallEndpoint.Product))
return true;

try
{
// initiate the download
_app.UpdateEndpoint.Model.Uid = _options.UID;
await _app.UpdateEndpoint.Post();
}
catch (AgentException ex) when (ex.ErrorCode == 2421)
{
Console.WriteLine("Agent returned 2421 (minimum specs and/or disk space requirement not met).");
Console.WriteLine("Double-check the install directory/drive has enough free space and that the product meets OS/hardware requirements.");
}

// 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;
if (_app.UpdateEndpoint.Product != null)
{
if (await PrintProgress(_app.UpdateEndpoint.Product))
return true;
}

// failing that another agent or the BNet app has
// probably taken control of the install
Expand Down
32 changes: 28 additions & 4 deletions BNetInstaller/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Globalization;
using BNetInstaller.Operations;

namespace BNetInstaller;
Expand All @@ -21,8 +22,28 @@ private static async Task Run(Options options)
using AgentApp app = new();
options.Sanitise();

var locale = options.Locale.ToString();
var mode = options.Repair ? Mode.Repair : Mode.Install;
// check if target directory exists before requesting bnet agent to validate
Directory.CreateDirectory(options.Directory);

if (options.Verbose)
{
try
{
var root = Path.GetPathRoot(options.Directory);
if (!string.IsNullOrWhiteSpace(root))
{
var drive = new DriveInfo(root);
var gib = drive.AvailableFreeSpace / (1024d * 1024d * 1024d);
Console.WriteLine($"Target drive free space: {gib.ToString("F2", CultureInfo.InvariantCulture)} GiB");
}
}
catch
{
}
}

var localeCode = options.Locale.ToString().ToLowerInvariant();
var mode = options.Repair ? Mode.Repair : Mode.Install;

Console.WriteLine("Authenticating");
await app.AgentEndpoint.Get();
Expand All @@ -34,6 +55,9 @@ private static async Task Run(Options options)

Console.WriteLine($"Starting {mode}");
app.InstallEndpoint.Product.Model.GameDir = options.Directory;
app.InstallEndpoint.Product.Model.Language = [localeCode];
app.InstallEndpoint.Product.Model.SelectedLocale = localeCode;
app.InstallEndpoint.Product.Model.SelectedAssetLocale = localeCode;
await app.InstallEndpoint.Product.Post();

Console.WriteLine();
Expand All @@ -52,7 +76,7 @@ private static async Task Run(Options options)
await app.AgentEndpoint.Delete();

// run the post download app/script if applicable
if (complete && File.Exists(options.PostDownload))
if (complete && !string.IsNullOrWhiteSpace(options.PostDownload) && File.Exists(options.PostDownload))
Process.Start(options.PostDownload);
}
}
Expand Down