[LOADED_DOCS: .github\copilot-instructions.md, C:\Users\Fullepi\copilot-instructions.md]

Refactor SignalR client DI and config, add test factory

Refactored FruitBankSignalRClient construction to use DI and centralized configuration from appsettings.json across all platforms. Introduced TestSignalRClientFactory for consistent test setup. Added FruitBankHubConnectionExtensions for reusable SignalR connection and logging configuration. Updated Program.cs and MauiProgram.cs to register logger factories, log writers, and IHubConnectionBuilder via DI. Embedded appsettings.json in MAUI and updated .csproj references for build flexibility. No business logic changes; all updates are infrastructure and test setup.
This commit is contained in:
Loretta 2026-04-22 22:45:32 +02:00
parent 10f325cc26
commit 33d84a8257
16 changed files with 318 additions and 72 deletions

View File

@ -61,6 +61,14 @@ You are operating in a multi-repo, documentation-first architecture. You MUST ST
> For nopCommerce plugin (server side) rules see: `../NopCommerce.Common/4.70/Plugins/Nop.Plugin.Misc.AIPlugin/README.md` (in Mango.Nop Plugins repo) > For nopCommerce plugin (server side) rules see: `../NopCommerce.Common/4.70/Plugins/Nop.Plugin.Misc.AIPlugin/README.md` (in Mango.Nop Plugins repo)
> External repos in `own-dep-repos` are fully accessible — read their source code, docs, and `.github/copilot-instructions.md` freely when you need type definitions, base classes, or context. Do not limit yourself to the current workspace. > External repos in `own-dep-repos` are fully accessible — read their source code, docs, and `.github/copilot-instructions.md` freely when you need type definitions, base classes, or context. Do not limit yourself to the current workspace.
## Shared Agent Skills
Skills defined in other repos that can be referenced from here:
- **protocol-audit** — Cross-repo consistency audit for `.github/copilot-instructions.md` across all 5 repos.
Location: `AyCode.Core/.github/skills/protocol-audit/SKILL.md`
Activate from an AyCode.Core session, or read the SKILL.md directly and follow its steps.
## Business Domain ## Business Domain
1. **FruitBank** = fruit & vegetable wholesaler. The server side runs as a **nopCommerce plugin** — Customer, Order, Product, GenericAttribute are nopCommerce entities. 1. **FruitBank** = fruit & vegetable wholesaler. The server side runs as a **nopCommerce plugin** — Customer, Order, Product, GenericAttribute are nopCommerce entities.
2. **Shipping** = INBOUND delivery (supplier → warehouse). **Order** = OUTBOUND delivery (warehouse → customer). Never confuse the two directions. 2. **Shipping** = INBOUND delivery (supplier → warehouse). **Order** = OUTBOUND delivery (warehouse → customer). Never confuse the two directions.

View File

@ -31,11 +31,7 @@ namespace FruitBankHybrid.Shared.Tests
{ {
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!"); if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
_signalRClient = new FruitBankSignalRClient(new List<IAcLogWriterClientBase> _signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankClientTests));
{
//new ConsoleLogWriter(AppType.TestUnit, LogLevel.Detail, nameof(FruitBankClientTests)),
new SignaRClientLogItemWriter(AppType.TestUnit, LogLevel.Detail, nameof(FruitBankClientTests))
});
} }
#region Partner #region Partner

View File

@ -190,10 +190,7 @@ public sealed class JsonExtensionTests
{ {
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!"); if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
_signalRClient = new FruitBankSignalRClient(new List<IAcLogWriterClientBase> _signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankClientTests));
{
new SignaRClientLogItemWriter(AppType.TestUnit, LogLevel.Detail, nameof(FruitBankClientTests))
});
} }
[TestMethod] [TestMethod]

View File

@ -28,11 +28,7 @@ public sealed class OrderClientTests
{ {
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!"); if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
_signalRClient = new FruitBankSignalRClient(new List<IAcLogWriterClientBase> _signalRClient = TestSignalRClientFactory.Create(nameof(OrderClientTests));
{
//new ConsoleLogWriter(AppType.TestUnit, LogLevel.Detail, nameof(FruitBankClientTests)),
new SignaRClientLogItemWriter(AppType.TestUnit, LogLevel.Detail, nameof(FruitBankClientTests))
});
} }

View File

@ -15,8 +15,8 @@ namespace FruitBankHybrid.Shared.Tests;
/// <summary> /// <summary>
/// Teszt a TestSignalREndpoint-hoz. /// Teszt a TestSignalREndpoint-hoz.
/// FONTOS: A SANDBOX-ot manuálisan kell elindítani a tesztek futtatása előtt! /// FONTOS: A SANDBOX-ot manu<EFBFBD>lisan kell elind<6E>tani a tesztek futtat<61>sa el<65>tt!
/// Indítás: dotnet run --project Mango.Sandbox.EndPoints --urls http://localhost:59579 /// Ind<EFBFBD>t<EFBFBD>s: dotnet run --project Mango.Sandbox.EndPoints --urls http://localhost:59579
/// </summary> /// </summary>
[TestClass] [TestClass]
public class SandboxEndpointSimpleTests public class SandboxEndpointSimpleTests
@ -24,7 +24,7 @@ public class SandboxEndpointSimpleTests
private static readonly string SandboxUrl = FruitBankConstClient.BaseUrl; //"http://localhost:59579"; private static readonly string SandboxUrl = FruitBankConstClient.BaseUrl; //"http://localhost:59579";
private static readonly string HubUrl = $"{SandboxUrl}/fbHub"; private static readonly string HubUrl = $"{SandboxUrl}/fbHub";
// Teszt SignalR Tags (TestSignalRTags-ből) // Teszt SignalR Tags (TestSignalRTags-b<EFBFBD>l)
private const int PingTag = SignalRTags.PingTag; private const int PingTag = SignalRTags.PingTag;
private const int EchoTag = SignalRTags.EchoTag; private const int EchoTag = SignalRTags.EchoTag;
private const int GetTestItemsTag = 9003; private const int GetTestItemsTag = 9003;
@ -34,13 +34,9 @@ public class SandboxEndpointSimpleTests
[TestInitialize] [TestInitialize]
public void TestInit() public void TestInit()
{ {
if (!SandboxUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!"); if (!SandboxUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTEL<EFBFBD>NK!");
_signalRClient = new FruitBankSignalRClient(new List<IAcLogWriterClientBase> _signalRClient = TestSignalRClientFactory.Create(nameof(SandboxEndpointSimpleTests));
{
//new ConsoleLogWriter(AppType.TestUnit, LogLevel.Detail, nameof(FruitBankClientTests)),
new SignaRClientLogItemWriter(AppType.TestUnit, LogLevel.Detail, nameof(SandboxEndpointSimpleTests))
});
} }
#region HTTP Endpoint Tests #region HTTP Endpoint Tests
@ -121,7 +117,7 @@ public class SandboxEndpointSimpleTests
// using var jsonDoc = JsonDocument.Parse(response); // using var jsonDoc = JsonDocument.Parse(response);
// var root = jsonDoc.RootElement; // var root = jsonDoc.RootElement;
// // Ellenőrizzük, hogy van Message property // // Ellen<EFBFBD>rizz<EFBFBD>k, hogy van Message property
// Assert.IsTrue(root.TryGetProperty("Message", out var messageElement) || // Assert.IsTrue(root.TryGetProperty("Message", out var messageElement) ||
// root.TryGetProperty("message", out messageElement), // root.TryGetProperty("message", out messageElement),
// "Response should contain 'Message' property"); // "Response should contain 'Message' property");
@ -141,13 +137,13 @@ public class SandboxEndpointSimpleTests
// using var jsonDoc = JsonDocument.Parse(response); // using var jsonDoc = JsonDocument.Parse(response);
// var root = jsonDoc.RootElement; // var root = jsonDoc.RootElement;
// // Ellenőrizzük az Id-t // // Ellen<EFBFBD>rizz<EFBFBD>k az Id-t
// Assert.IsTrue(root.TryGetProperty("Id", out var idElement) || // Assert.IsTrue(root.TryGetProperty("Id", out var idElement) ||
// root.TryGetProperty("id", out idElement), // root.TryGetProperty("id", out idElement),
// "Response should contain 'Id' property"); // "Response should contain 'Id' property");
// Assert.AreEqual(42, idElement.GetInt32(), "Id should be 42"); // Assert.AreEqual(42, idElement.GetInt32(), "Id should be 42");
// // Ellenőrizzük a Name-et // // Ellen<EFBFBD>rizz<EFBFBD>k a Name-et
// Assert.IsTrue(root.TryGetProperty("Name", out var nameElement) || // Assert.IsTrue(root.TryGetProperty("Name", out var nameElement) ||
// root.TryGetProperty("name", out nameElement), // root.TryGetProperty("name", out nameElement),
// "Response should contain 'Name' property"); // "Response should contain 'Name' property");
@ -167,13 +163,13 @@ public class SandboxEndpointSimpleTests
// using var jsonDoc = JsonDocument.Parse(response); // using var jsonDoc = JsonDocument.Parse(response);
// var root = jsonDoc.RootElement; // var root = jsonDoc.RootElement;
// // Ellenőrizzük, hogy tömb-e // // Ellen<EFBFBD>rizz<EFBFBD>k, hogy t<>mb-e
// Assert.AreEqual(JsonValueKind.Array, root.ValueKind, "Response should be an array"); // Assert.AreEqual(JsonValueKind.Array, root.ValueKind, "Response should be an array");
// Assert.IsTrue(root.GetArrayLength() > 0, "Array should have items"); // Assert.IsTrue(root.GetArrayLength() > 0, "Array should have items");
// Console.WriteLine($"[GetTestItems] Received {root.GetArrayLength()} items"); // Console.WriteLine($"[GetTestItems] Received {root.GetArrayLength()} items");
// // Ellenőrizzük az első elemet // // Ellen<EFBFBD>rizz<EFBFBD>k az els<6C> elemet
// var firstItem = root[0]; // var firstItem = root[0];
// Assert.IsTrue(firstItem.TryGetProperty("Id", out _) || firstItem.TryGetProperty("id", out _), // Assert.IsTrue(firstItem.TryGetProperty("Id", out _) || firstItem.TryGetProperty("id", out _),
// "Item should have 'Id' property"); // "Item should have 'Id' property");
@ -187,8 +183,8 @@ public class SandboxEndpointSimpleTests
//#region EREDETI BUSINESS ENDPOINT TESZTEK - KIKOMMENTEZVE //#region EREDETI BUSINESS ENDPOINT TESZTEK - KIKOMMENTEZVE
//// =========================================== //// ===========================================
//// === Az alábbi tesztek az eredeti 3 endpoint-ot tesztelik === //// === Az al<EFBFBD>bbi tesztek az eredeti 3 endpoint-ot tesztelik ===
//// === Visszaállításhoz: töröld a kommenteket és regisztráld az endpoint-okat a Program.cs-ben === //// === Vissza<EFBFBD>ll<EFBFBD>t<EFBFBD>shoz: t<>r<EFBFBD>ld a kommenteket <20>s regisztr<74>ld az endpoint-okat a Program.cs-ben ===
//// =========================================== //// ===========================================
//// [TestMethod] //// [TestMethod]
@ -260,13 +256,13 @@ public class SandboxEndpointSimpleTests
// await connection.StartAsync(); // await connection.StartAsync();
// Assert.AreEqual(HubConnectionState.Connected, connection.State, $"Failed to connect to SignalR hub for {endpointName}"); // Assert.AreEqual(HubConnectionState.Connected, connection.State, $"Failed to connect to SignalR hub for {endpointName}");
// // Készítsük el a request data-t // // K<EFBFBD>sz<EFBFBD>ts<EFBFBD>k el a request data-t
// // Ha nincs paraméter, null-t küldünk (nem üres byte tömböt!) // // Ha nincs param<EFBFBD>ter, null-t k<>ld<6C>nk (nem <20>res byte t<>mb<6D>t!)
// byte[]? requestData = parameter != null // byte[]? requestData = parameter != null
// ? Encoding.UTF8.GetBytes(JsonSerializer.Serialize(parameter)) // ? Encoding.UTF8.GetBytes(JsonSerializer.Serialize(parameter))
// : null; // : null;
// // A Hub metódus neve: OnReceiveMessage (3 paraméter: messageTag, messageBytes, requestId) // // A Hub met<EFBFBD>dus neve: OnReceiveMessage (3 param<61>ter: messageTag, messageBytes, requestId)
// await connection.InvokeAsync("OnReceiveMessage", tag, requestData, (int?)null); // await connection.InvokeAsync("OnReceiveMessage", tag, requestData, (int?)null);
// var completed = await Task.WhenAny(responseReceived.Task, Task.Delay(15000)); // var completed = await Task.WhenAny(responseReceived.Task, Task.Delay(15000));
@ -276,7 +272,7 @@ public class SandboxEndpointSimpleTests
// Console.WriteLine($"[{endpointName}] Response tag: {receivedTag}"); // Console.WriteLine($"[{endpointName}] Response tag: {receivedTag}");
// Console.WriteLine($"[{endpointName}] Response JSON: {receivedJson?.Substring(0, Math.Min(500, receivedJson?.Length ?? 0))}..."); // Console.WriteLine($"[{endpointName}] Response JSON: {receivedJson?.Substring(0, Math.Min(500, receivedJson?.Length ?? 0))}...");
// // Ellenőrizzük, hogy valid JSON-e (ha van adat) // // Ellen<EFBFBD>rizz<EFBFBD>k, hogy valid JSON-e (ha van adat)
// if (!string.IsNullOrEmpty(receivedJson)) // if (!string.IsNullOrEmpty(receivedJson))
// { // {
// try // try

View File

@ -0,0 +1,53 @@
using AyCode.Core.Enums;
using AyCode.Core.Loggers;
using AyCode.Core.Serializers.Binaries;
using AyCode.Services.SignalRs;
using FruitBank.Common;
using FruitBank.Common.Loggers;
using FruitBankHybrid.Shared.Services.Loggers;
using FruitBankHybrid.Shared.Services.SignalRs;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR.Client;
namespace FruitBankHybrid.Shared.Tests;
/// <summary>
/// Test-only factory for <see cref="FruitBankSignalRClient"/>. Builds a <c>HubConnectionBuilder</c>
/// with the same connection settings a production <c>Program.cs</c> would use, wires a logger factory
/// backed by a single <c>SignaRClientLogItemWriter</c> (test-unit AppType, Detail level),
/// and uses <see cref="BinaryProtocolMode.AsyncSegment"/> for the protocol.
/// </summary>
internal static class TestSignalRClientFactory
{
public static FruitBankSignalRClient Create(string testCategoryName)
{
var logWriters = new List<IAcLogWriterClientBase>
{
new SignaRClientLogItemWriter(AppType.TestUnit, LogLevel.Detail, testCategoryName)
};
Func<string, AcLoggerBase> loggerFactory =
categoryName => new LoggerClient(categoryName, logWriters.ToArray());
var connectionOptions = new AcHubConnectionOptions
{
Url = $"{FruitBankConstClient.BaseUrl}/{FruitBankConstClient.DefaultHubName}",
TransportMaxBufferSize = 30_000_000,
ApplicationMaxBufferSize = 30_000_000,
CloseTimeout = TimeSpan.FromSeconds(10),
KeepAliveInterval = TimeSpan.FromSeconds(60),
ServerTimeout = TimeSpan.FromSeconds(180),
SkipNegotiation = true,
Transports = HttpTransportType.WebSockets,
UseAutomaticReconnect = true,
UseStatefulReconnect = true
};
var logger = loggerFactory(nameof(FruitBankSignalRClient));
var hubBuilder = new HubConnectionBuilder().AddFruitBankDefaults(logger, connectionOptions);
hubBuilder.AddAcBinaryProtocol(opts => opts.ProtocolMode = BinaryProtocolMode.AsyncSegment);
return new FruitBankSignalRClient(hubBuilder, loggerFactory);
}
}

View File

@ -101,11 +101,7 @@ public sealed class ToonTests
{ {
if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!"); if (!FruitBankConstClient.BaseUrl.Contains("localhost:")) throw new Exception("NEM LOCALHOST-ON TESZTELÜNK!");
_signalRClient = new FruitBankSignalRClient(new List<IAcLogWriterClientBase> _signalRClient = TestSignalRClientFactory.Create(nameof(FruitBankClientTests));
{
//new ConsoleLogWriter(AppType.TestUnit, LogLevel.Detail, nameof(FruitBankClientTests)),
new SignaRClientLogItemWriter(AppType.TestUnit, LogLevel.Detail, nameof(FruitBankClientTests))
});
} }
#if DEBUG #if DEBUG

View File

@ -0,0 +1,35 @@
using AyCode.Core.Loggers;
using AyCode.Services.SignalRs;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
namespace FruitBankHybrid.Shared.Services.SignalRs;
/// <summary>
/// FruitBank-specific hub-connection setup.
/// <para>
/// Connection-level configuration (URL, buffers, timeouts, reconnect) is delegated to the
/// framework's <see cref="AcSignalRConnectionExtensions.AddAcConnection"/>, driven by
/// <see cref="AcHubConnectionOptions"/> from <c>appsettings.json</c>. The logger is supplied
/// by the caller (typically via a DI-registered <c>Func&lt;string, AcLoggerBase&gt;</c> factory
/// in <c>Program.cs</c>), keeping this extension free of <c>LoggerClient</c> construction.
/// </para>
/// </summary>
public static class FruitBankHubConnectionExtensions
{
/// <summary>
/// Applies <see cref="AcSignalRConnectionExtensions.AddAcConnection"/> with the given
/// <paramref name="connectionOptions"/>, then bridges <paramref name="logger"/> into
/// SignalR's internal <c>ILogger</c> pipeline.
/// </summary>
public static IHubConnectionBuilder AddFruitBankDefaults(this IHubConnectionBuilder builder, AcLoggerBase logger, AcHubConnectionOptions connectionOptions)
{
return builder
.AddAcConnection(connectionOptions)
.ConfigureLogging(logging =>
{
logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Information);
logging.AddAcLogger(_ => logger);
});
}
}

View File

@ -26,14 +26,9 @@ namespace FruitBankHybrid.Shared.Services.SignalRs
{ {
public class FruitBankSignalRClient : AcSignalRClientBase, IFruitBankDataControllerClient, ICustomOrderSignalREndpointClient, IStockSignalREndpointClient public class FruitBankSignalRClient : AcSignalRClientBase, IFruitBankDataControllerClient, ICustomOrderSignalREndpointClient, IStockSignalREndpointClient
{ {
public FruitBankSignalRClient( /*IServiceProvider serviceProvider, */ IEnumerable<IAcLogWriterClientBase> logWriters) : base($"{FruitBankConstClient.BaseUrl}/{FruitBankConstClient.DefaultHubName}", new LoggerClient(nameof(FruitBankSignalRClient), logWriters.ToArray())) public FruitBankSignalRClient(IHubConnectionBuilder hubBuilder, Func<string, AcLoggerBase> loggerFactory)
: base(hubBuilder, loggerFactory(nameof(FruitBankSignalRClient)))
{ {
//var hubConnection = new HubConnectionBuilder()
// .WithUrl("fullHubName")
// .WithAutomaticReconnect()
// .WithStatefulReconnect()
// .WithKeepAliveInterval(TimeSpan.FromSeconds(60))
// .WithServerTimeout(TimeSpan.FromSeconds(120))
EnableBinaryDiagnostics = FruitBankConstClient.SignalRSerializerDiagnosticLog; EnableBinaryDiagnostics = FruitBankConstClient.SignalRSerializerDiagnosticLog;
ConstHelper.NameByValue<SignalRTags>(0); ConstHelper.NameByValue<SignalRTags>(0);
} }

View File

@ -1,4 +1,5 @@
using AyCode.Core.Loggers; using AyCode.Core.Loggers;
using AyCode.Core.Serializers.Binaries;
using AyCode.Services.SignalRs; using AyCode.Services.SignalRs;
using FruitBank.Common; using FruitBank.Common;
using FruitBank.Common.Loggers; using FruitBank.Common.Loggers;
@ -6,11 +7,13 @@ using FruitBank.Common.Models;
using FruitBank.Common.Services; using FruitBank.Common.Services;
using FruitBankHybrid.Shared.Databases; using FruitBankHybrid.Shared.Databases;
using FruitBankHybrid.Shared.Services; using FruitBankHybrid.Shared.Services;
using FruitBankHybrid.Shared.Services.Loggers;
using FruitBankHybrid.Shared.Services.SignalRs; using FruitBankHybrid.Shared.Services.SignalRs;
using FruitBankHybrid.Web.Client.Services; using FruitBankHybrid.Web.Client.Services;
using FruitBankHybrid.Web.Client.Services.Loggers; using FruitBankHybrid.Web.Client.Services.Loggers;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Options;
var builder = WebAssemblyHostBuilder.CreateDefault(args); var builder = WebAssemblyHostBuilder.CreateDefault(args);
@ -20,17 +23,47 @@ builder.Services.AddDevExpressBlazor(configure => configure.SizeMode = DevExpres
builder.Services.AddSingleton<IFormFactor, FormFactor>(); builder.Services.AddSingleton<IFormFactor, FormFactor>();
builder.Services.AddSingleton<ISecureCredentialService, WebSecureCredentialService>(); builder.Services.AddSingleton<ISecureCredentialService, WebSecureCredentialService>();
//#if DEBUG #if DEBUG
builder.Services.AddSingleton<IAcLogWriterClientBase, BrowserConsoleLogWriter>(); builder.Services.AddSingleton<IAcLogWriterClientBase, BrowserConsoleLogWriter>();
//#endif #endif
builder.Services.AddSingleton<IAcLogWriterClientBase, SignaRClientLogItemWriter>();
builder.Services.AddSingleton<LoggedInModel>(sp => new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>()));
// Bind SignalR options from wwwroot/appsettings.json (loaded automatically by WebAssemblyHostBuilder) —
// single Configure call per options type, combining section Bind with runtime overrides.
builder.Services.Configure<AcHubConnectionOptions>(opts => builder.Configuration.GetSection("AcHubConnection").Bind(opts));
builder.Services.Configure<AcBinaryHubProtocolOptions>(opts =>
{
builder.Configuration.GetSection("AcBinaryHubProtocol").Bind(opts);
// WASM safety net: AsyncSegment send-path is unsupported here — Validate() would throw.
// Downgrade if appsettings.json accidentally specifies it.
if (opts.ProtocolMode == BinaryProtocolMode.AsyncSegment) opts.ProtocolMode = BinaryProtocolMode.Segment;
});
// Logger factory — thin Logger, DI-singleton writers. Each call yields a fresh LoggerClient
// with the caller's categoryName. Mirrors Microsoft's ILoggerFactory / ILogger<T> pattern.
builder.Services.AddSingleton<Func<string, AcLoggerBase>>(sp => categoryName => new LoggerClient(categoryName, sp.GetServices<IAcLogWriterClientBase>().ToArray()));
// HubConnectionBuilder — transient so each consumer gets a fresh builder to Build().
// All connection and protocol configuration flows from appsettings.json via IOptions<T>;
// AddFruitBankDefaults only bridges the provided logger into SignalR's internal pipeline.
builder.Services.AddTransient<IHubConnectionBuilder>(sp =>
{
var loggerFactory = sp.GetRequiredService<Func<string, AcLoggerBase>>();
var connectionOpts = sp.GetRequiredService<IOptions<AcHubConnectionOptions>>().Value;
var logger = loggerFactory(nameof(FruitBankSignalRClient));
var hubBuilder = new HubConnectionBuilder().AddFruitBankDefaults(logger, connectionOpts);
hubBuilder.AddAcBinaryProtocol(); // IOptions<AcBinaryHubProtocolOptions> from DI
return hubBuilder;
});
builder.Services.AddSingleton<LoggedInModel>(sp =>
new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>()));
builder.Services.AddSingleton<FruitBankSignalRClient>(); builder.Services.AddSingleton<FruitBankSignalRClient>();
builder.Services.AddSingleton<DatabaseClient>(); builder.Services.AddSingleton<DatabaseClient>();
builder.Services.AddSingleton<IAcLogWriterClientBase, SignaRClientLogItemWriter>();
#if DEBUG #if DEBUG
if (FruitBankConstClient.SignalRSerializerDiagnosticLog) if (FruitBankConstClient.SignalRSerializerDiagnosticLog)
{ {

View File

@ -4,5 +4,25 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"AcHubConnection": {
"Url": "https://localhost:59579/fbHub",
"TransportMaxBufferSize": 30000000,
"ApplicationMaxBufferSize": 30000000,
"CloseTimeout": "00:00:10",
"KeepAliveInterval": "00:01:00",
"ServerTimeout": "00:03:00",
"SkipNegotiation": true,
"Transports": "WebSockets",
"UseAutomaticReconnect": true,
"UseStatefulReconnect": true
},
"AcBinaryHubProtocol": {
"ProtocolMode": "Segment",
"BufferSize": 4096,
"WaitForFlush": true,
"FlushTimeout": "00:00:10"
} }
} }

View File

@ -1,4 +1,6 @@
using AyCode.Core.Loggers; using AyCode.Core.Loggers;
using AyCode.Core.Serializers.Binaries;
using AyCode.Services.SignalRs;
using FruitBank.Common; using FruitBank.Common;
using FruitBank.Common.Models; using FruitBank.Common.Models;
using FruitBank.Common.Services; using FruitBank.Common.Services;
@ -6,9 +8,12 @@ using FruitBank.Common.Server.Services.Loggers;
using FruitBank.Common.Server.Services.SignalRs; using FruitBank.Common.Server.Services.SignalRs;
using FruitBankHybrid.Shared.Databases; using FruitBankHybrid.Shared.Databases;
using FruitBankHybrid.Shared.Services; using FruitBankHybrid.Shared.Services;
using FruitBankHybrid.Shared.Services.Loggers;
using FruitBankHybrid.Shared.Services.SignalRs; using FruitBankHybrid.Shared.Services.SignalRs;
using FruitBankHybrid.Web.Components; using FruitBankHybrid.Web.Components;
using FruitBankHybrid.Web.Services; using FruitBankHybrid.Web.Services;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -21,8 +26,40 @@ builder.Services.AddSingleton<IFormFactor, FormFactor>();
builder.Services.AddSingleton<ISecureCredentialService, ServerSecureCredentialService>(); builder.Services.AddSingleton<ISecureCredentialService, ServerSecureCredentialService>();
builder.Services.AddSingleton<IAcLogWriterBase, ConsoleLogWriter>(); builder.Services.AddSingleton<IAcLogWriterBase, ConsoleLogWriter>();
builder.Services.AddSingleton<LoggedInModel>(sp => // Logger factory — thin Logger, DI-singleton writers. Each call yields a fresh LoggerClient
new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>())); // with the caller's categoryName. Mirrors Microsoft's ILoggerFactory / ILogger<T> pattern.
builder.Services.AddSingleton<Func<string, AcLoggerBase>>(sp => categoryName => new LoggerClient(categoryName, sp.GetServices<IAcLogWriterClientBase>().ToArray()));
builder.Services.AddSingleton<LoggedInModel>(sp => new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>()));
// Bind SignalR options from appsettings.json — single Configure call per options type.
// The lambda runs the appsettings Bind first, then any runtime overrides (e.g. the WASM safety net).
builder.Services.Configure<AcHubConnectionOptions>(opts => builder.Configuration.GetSection("AcHubConnection").Bind(opts));
builder.Services.Configure<AcBinaryHubProtocolOptions>(opts =>
{
builder.Configuration.GetSection("AcBinaryHubProtocol").Bind(opts);
// Platform safety net: on WebAssembly the AsyncSegment send-path is unsupported
// (Validate() would throw). No-op on this server host, but matches the contract.
if (OperatingSystem.IsBrowser() && opts.ProtocolMode == BinaryProtocolMode.AsyncSegment)
opts.ProtocolMode = BinaryProtocolMode.Segment;
});
// HubConnectionBuilder — transient so each consumer gets a fresh builder to Build().
// All connection and protocol configuration flows from appsettings.json via IOptions<T>;
// AddFruitBankDefaults only bridges the provided logger into SignalR's internal pipeline.
builder.Services.AddTransient<IHubConnectionBuilder>(sp =>
{
var loggerFactory = sp.GetRequiredService<Func<string, AcLoggerBase>>();
var connectionOpts = sp.GetRequiredService<IOptions<AcHubConnectionOptions>>().Value;
var logger = loggerFactory(nameof(FruitBankSignalRClient));
var hubBuilder = new HubConnectionBuilder().AddFruitBankDefaults(logger, connectionOpts);
hubBuilder.AddAcBinaryProtocol(); // IOptions<AcBinaryHubProtocolOptions> from DI
return hubBuilder;
});
builder.Services.AddSingleton<FruitBankSignalRClient>(); builder.Services.AddSingleton<FruitBankSignalRClient>();
builder.Services.AddSingleton<DatabaseClient>(); builder.Services.AddSingleton<DatabaseClient>();
@ -57,8 +94,6 @@ app.MapStaticAssets();
app.MapRazorComponents<App>() app.MapRazorComponents<App>()
//.AddInteractiveServerRenderMode() //.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode() .AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies( .AddAdditionalAssemblies(typeof(FruitBankHybrid.Shared._Imports).Assembly, typeof(FruitBankHybrid.Web.Client._Imports).Assembly);
typeof(FruitBankHybrid.Shared._Imports).Assembly,
typeof(FruitBankHybrid.Web.Client._Imports).Assembly);
app.Run(); app.Run();

View File

@ -1,12 +1,32 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Debug",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"AcHubConnection": {
"Url": "https://localhost:59579/fbHub",
"TransportMaxBufferSize": 30000000,
"ApplicationMaxBufferSize": 30000000,
"CloseTimeout": "00:00:10",
"KeepAliveInterval": "00:01:00",
"ServerTimeout": "00:03:00",
"SkipNegotiation": true,
"Transports": "WebSockets",
"UseAutomaticReconnect": true,
"UseStatefulReconnect": true
},
"AcBinaryHubProtocol": {
"ProtocolMode": "AsyncSegment",
"BufferSize": 4096,
"WaitForFlush": true,
"FlushTimeout": "00:00:10"
},
"AyCode": { "AyCode": {
"ProjectId": "aad53443-2ee2-4650-8a99-97e907265e4e", "ProjectId": "aad53443-2ee2-4650-8a99-97e907265e4e",
"Urls": { "Urls": {
@ -19,7 +39,7 @@
"LogWriters": [ "LogWriters": [
{ {
"LogLevel": "Detail", "LogLevel": "Detail",
"LogWriterType": "FruitBank.Common.Server.Services.Loggers.ConsoleLogWriter, FruitBank.Common.Server, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "LogWriterType": "FruitBank.Common.Server.Services.Loggers.ConsoleLogWriter, FruitBank.Common.Server, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
} }
] ]
} }

View File

@ -78,6 +78,11 @@
<AndroidKeyStore>True</AndroidKeyStore> <AndroidKeyStore>True</AndroidKeyStore>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<!-- appsettings.json — embedded so MauiAppBuilder can load via GetManifestResourceStream -->
<EmbeddedResource Include="appsettings.json" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<!-- App Icon --> <!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" /> <MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
@ -131,11 +136,11 @@
<Reference Include="AyCode.Entities"> <Reference Include="AyCode.Entities">
<HintPath>..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Entities.dll</HintPath> <HintPath>..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Entities.dll</HintPath>
</Reference> </Reference>
<!--<Reference Include="Mango.Nop.Core"> <Reference Include="Mango.Nop.Core">
<HintPath>..\..\NopCommerce.Common\4.70\Libraries\Mango.Nop.Core\bin\FruitBank\Debug\net9.0\Mango.Nop.Core.dll</HintPath> <HintPath>..\..\NopCommerce.Common\4.70\Libraries\Mango.Nop.Core\bin\FruitBank\$(Configuration)\net9.0\Mango.Nop.Core.dll</HintPath>
</Reference> </Reference>
<Reference Include="Nop.Core"> <!--<Reference Include="Nop.Core">
<HintPath>..\..\NopCommerce.Common\4.70\Libraries\Mango.Nop.Core\bin\FruitBank\Debug\net9.0\Nop.Core.dll</HintPath> <HintPath>..\..\NopCommerce.Common\4.70\Libraries\Mango.Nop.Core\bin\FruitBank\$(Configuration)\net9.0\Nop.Core.dll</HintPath>
</Reference>--> </Reference>-->
</ItemGroup> </ItemGroup>

View File

@ -1,4 +1,5 @@
using AyCode.Core.Loggers; using AyCode.Core.Loggers;
using AyCode.Services.SignalRs;
using FruitBank.Common.Loggers; using FruitBank.Common.Loggers;
using FruitBank.Common.Models; using FruitBank.Common.Models;
using FruitBank.Common.Services; using FruitBank.Common.Services;
@ -6,9 +7,15 @@ using FruitBankHybrid.Services;
using FruitBankHybrid.Services.Loggers; using FruitBankHybrid.Services.Loggers;
using FruitBankHybrid.Shared.Databases; using FruitBankHybrid.Shared.Databases;
using FruitBankHybrid.Shared.Services; using FruitBankHybrid.Shared.Services;
using FruitBankHybrid.Shared.Services.Loggers;
using FruitBankHybrid.Shared.Services.SignalRs; using FruitBankHybrid.Shared.Services.SignalRs;
//using DevExpress.Maui; //using DevExpress.Maui;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Reflection;
namespace FruitBankHybrid namespace FruitBankHybrid
{ {
@ -27,21 +34,55 @@ namespace FruitBankHybrid
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
}); });
// Add device-specific services used by the FruitBankHybrid.Shared project // Load embedded appsettings.json — MAUI has no automatic config file discovery,
builder.Services.AddSingleton<IFormFactor, FormFactor>(); // so the JSON is shipped as an EmbeddedResource (see FruitBankHybrid.csproj).
builder.Services.AddSingleton<ISecureCredentialService, MauiSecureCredentialService>(); using (var appsettingsStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("FruitBankHybrid.appsettings.json"))
{
if (appsettingsStream is not null)
{
var jsonConfig = new ConfigurationBuilder().AddJsonStream(appsettingsStream).Build();
builder.Configuration.AddConfiguration(jsonConfig);
}
}
#if DEBUG #if DEBUG
builder.Services.AddSingleton<IAcLogWriterClientBase, BrowserConsoleLogWriter>(); builder.Services.AddSingleton<IAcLogWriterClientBase, BrowserConsoleLogWriter>();
#endif #endif
builder.Services.AddSingleton<LoggedInModel>(sp =>
new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>()));
builder.Services.AddSingleton<FruitBankSignalRClient>();
builder.Services.AddSingleton<DatabaseClient>();
builder.Services.AddSingleton<IAcLogWriterClientBase, SignaRClientLogItemWriter>(); builder.Services.AddSingleton<IAcLogWriterClientBase, SignaRClientLogItemWriter>();
// Logger factory — the Logger itself is a thin wrapper; writers (the real sinks)
// are DI singletons. Each call produces a fresh LoggerClient with the caller's categoryName.
// Mirrors the Microsoft ILoggerFactory / ILogger<T> pattern.
builder.Services.AddSingleton<Func<string, AcLoggerBase>>(sp => categoryName => new LoggerClient(categoryName, sp.GetServices<IAcLogWriterClientBase>().ToArray()));
// Bind SignalR options from configuration.
// Precedence: code default → appsettings.json (this line) → any later Configure<T> action.
builder.Services.Configure<AcHubConnectionOptions>(builder.Configuration.GetSection("AcHubConnection"));
builder.Services.Configure<AcBinaryHubProtocolOptions>(builder.Configuration.GetSection("AcBinaryHubProtocol"));
// Add device-specific services used by the FruitBankHybrid.Shared project
builder.Services.AddSingleton<IFormFactor, FormFactor>();
builder.Services.AddSingleton<ISecureCredentialService, MauiSecureCredentialService>();
builder.Services.AddSingleton<LoggedInModel>(sp => new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>()));
// SignalR HubConnectionBuilder — transient so each consumer gets a fresh builder to Build().
// All connection and protocol configuration flows from appsettings.json via IOptions<T>;
// AddFruitBankDefaults only bridges the provided logger into SignalR's internal pipeline.
builder.Services.AddTransient<IHubConnectionBuilder>(sp =>
{
var loggerFactory = sp.GetRequiredService<Func<string, AcLoggerBase>>();
var connectionOpts = sp.GetRequiredService<IOptions<AcHubConnectionOptions>>().Value;
var logger = loggerFactory(nameof(FruitBankSignalRClient));
var hubBuilder = new HubConnectionBuilder().AddFruitBankDefaults(logger, connectionOpts);
hubBuilder.AddAcBinaryProtocol(); // IOptions<AcBinaryHubProtocolOptions> from DI
return hubBuilder;
});
builder.Services.AddSingleton<FruitBankSignalRClient>();
builder.Services.AddSingleton<DatabaseClient>();
builder.Services.AddMauiBlazorWebView(); builder.Services.AddMauiBlazorWebView();
builder.Services.AddDevExpressBlazor(configure => configure.SizeMode = DevExpress.Blazor.SizeMode.Medium); builder.Services.AddDevExpressBlazor(configure => configure.SizeMode = DevExpress.Blazor.SizeMode.Medium);

View File

@ -0,0 +1,20 @@
{
"AcHubConnection": {
"Url": "https://localhost:59579/fbHub",
"TransportMaxBufferSize": 30000000,
"ApplicationMaxBufferSize": 30000000,
"CloseTimeout": "00:00:10",
"KeepAliveInterval": "00:01:00",
"ServerTimeout": "00:03:00",
"SkipNegotiation": true,
"Transports": "WebSockets",
"UseAutomaticReconnect": true,
"UseStatefulReconnect": true
},
"AcBinaryHubProtocol": {
"ProtocolMode": "AsyncSegment",
"BufferSize": 4096,
"WaitForFlush": true,
"FlushTimeout": "00:00:10"
}
}