From 30eea698fc7cb09edb0d31332d4fcc644be76601 Mon Sep 17 00:00:00 2001 From: HarryCordewener Date: Wed, 1 Jul 2026 10:54:40 -0500 Subject: [PATCH] refactor(di): shared AddSharpClientViewModels registration to prevent host drift The Web host (Web/Program.cs) and the MAUI host (App/MauiProgram.cs) each registered the six presentation view models independently, and they drifted: TriggerAliasEditorViewModel was registered on Web but not MAUI, which crashed the Rules page on Android with "No registered service of type 'TriggerAliasEditorViewModel'". Extract a single AddSharpClientViewModels(ServiceLifetime perViewLifetime) extension in SharpClient.UI (referenced by both hosts). It registers the three shared VMs (Sessions, ProtocolPanel, Settings) as Singleton and the three per-view VMs (WorldManager, HistorySearch, TriggerAliasEditor) with the caller-supplied lifetime. MAUI passes Transient, Web passes Scoped, preserving each host's previous lifetimes exactly. Both hosts now call the shared method, so the two can no longer diverge. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp --- src/SharpClient.App/MauiProgram.cs | 26 ++------- .../ServiceCollectionExtensions.cs | 58 +++++++++++++++++++ src/SharpClient.UI/SharpClient.UI.csproj | 1 + src/SharpClient.Web/Program.cs | 22 ++----- 4 files changed, 69 insertions(+), 38 deletions(-) create mode 100644 src/SharpClient.UI/ServiceCollectionExtensions.cs diff --git a/src/SharpClient.App/MauiProgram.cs b/src/SharpClient.App/MauiProgram.cs index 52c9083..02a5ca2 100644 --- a/src/SharpClient.App/MauiProgram.cs +++ b/src/SharpClient.App/MauiProgram.cs @@ -10,6 +10,7 @@ using SharpClient.Core.Sessions; using SharpClient.Core.Triggers; using SharpClient.Data; +using SharpClient.UI; namespace SharpClient.App; @@ -90,8 +91,6 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => - new SettingsViewModel(sp.GetRequiredService())); // ── Data / persistence ──────────────────────────────────────────── // AppDbContext is transient so each call gets a fresh context; this @@ -106,27 +105,14 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(sp => sp.GetRequiredService()); - // ── View models ─────────────────────────────────────────────────── - builder.Services.AddSingleton(sp => - new SessionsViewModel(sp.GetRequiredService())); - - builder.Services.AddSingleton(sp => - new ProtocolPanelViewModel(sp.GetRequiredService())); - - builder.Services.AddTransient(sp => - new WorldManagerViewModel( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService())); - builder.Services.AddTransient(sp => - new HistorySearchViewModel( - sp.GetRequiredService(), - sp.GetRequiredService())); - // ── Session launcher (real telnet) ──────────────────────────────── builder.Services.AddTransient(); + // ── View models ─────────────────────────────────────────────────── + // Registered via the shared extension so MAUI and Web stay in lockstep (no host drift). + // Per-view view models are Transient here to match MAUI's per-request-less lifetime model. + builder.Services.AddSharpClientViewModels(ServiceLifetime.Transient); + // ── Trigger / alias engines (stateless) ────────────────────────── builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/SharpClient.UI/ServiceCollectionExtensions.cs b/src/SharpClient.UI/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..5fc37e6 --- /dev/null +++ b/src/SharpClient.UI/ServiceCollectionExtensions.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.DependencyInjection; +using SharpClient.Core.Persistence; +using SharpClient.Core.Platform; +using SharpClient.Core.Presentation; +using SharpClient.Core.Sessions; + +namespace SharpClient.UI; + +/// +/// Shared DI registration for the presentation view models, referenced by BOTH the MAUI host +/// (SharpClient.App) and the Web host (SharpClient.Web). Keeping the registrations in +/// one place prevents host drift — previously each host registered the view models independently and +/// they diverged (e.g. was registered on Web but not MAUI, +/// crashing the Rules page on Android). +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers all six presentation view models. The three session/settings view models are always + /// singletons; the three per-view view models use (Transient + /// in MAUI, Scoped in Web). All constructor dependencies must already be registered by the host. + /// + public static IServiceCollection AddSharpClientViewModels( + this IServiceCollection services, + ServiceLifetime perViewLifetime) + { + // ── Shared-lifetime view models (Singleton in both hosts) ──────────── + services.AddSingleton(sp => + new SessionsViewModel(sp.GetRequiredService())); + services.AddSingleton(sp => + new ProtocolPanelViewModel(sp.GetRequiredService())); + services.AddSingleton(sp => + new SettingsViewModel(sp.GetRequiredService())); + + // ── Per-view view models (Transient in MAUI, Scoped in Web) ────────── + services.Add(new ServiceDescriptor( + typeof(WorldManagerViewModel), + sp => new WorldManagerViewModel( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService()), + perViewLifetime)); + services.Add(new ServiceDescriptor( + typeof(HistorySearchViewModel), + sp => new HistorySearchViewModel( + sp.GetRequiredService(), + sp.GetRequiredService()), + perViewLifetime)); + services.Add(new ServiceDescriptor( + typeof(TriggerAliasEditorViewModel), + sp => new TriggerAliasEditorViewModel( + sp.GetRequiredService()), + perViewLifetime)); + + return services; + } +} diff --git a/src/SharpClient.UI/SharpClient.UI.csproj b/src/SharpClient.UI/SharpClient.UI.csproj index 6b87ca8..497a13c 100644 --- a/src/SharpClient.UI/SharpClient.UI.csproj +++ b/src/SharpClient.UI/SharpClient.UI.csproj @@ -10,6 +10,7 @@ + diff --git a/src/SharpClient.Web/Program.cs b/src/SharpClient.Web/Program.cs index 206cc4f..eb4d49f 100644 --- a/src/SharpClient.Web/Program.cs +++ b/src/SharpClient.Web/Program.cs @@ -6,6 +6,7 @@ using SharpClient.Core.Sessions; using SharpClient.Core.Triggers; using SharpClient.Data; +using SharpClient.UI; using SharpClient.Web; using SharpClient.Web.Components; @@ -20,8 +21,6 @@ // ── Platform services ────────────────────────────────────────────────────── builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => - new SettingsViewModel(sp.GetRequiredService())); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -41,10 +40,6 @@ // ── Session management ───────────────────────────────────────────────────── builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(sp => - new SessionsViewModel(sp.GetRequiredService())); -builder.Services.AddSingleton(sp => - new ProtocolPanelViewModel(sp.GetRequiredService())); // ── Data / persistence ───────────────────────────────────────────────────── builder.Services.AddScoped(); @@ -55,18 +50,9 @@ builder.Services.AddScoped(); // ── View models ──────────────────────────────────────────────────────────── -builder.Services.AddScoped(sp => new WorldManagerViewModel( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService())); -builder.Services.AddScoped(sp => - new TriggerAliasEditorViewModel( - sp.GetRequiredService())); -builder.Services.AddScoped(sp => - new HistorySearchViewModel( - sp.GetRequiredService(), - sp.GetRequiredService())); +// Registered via the shared extension so MAUI and Web stay in lockstep (no host drift). +// Per-view view models are Scoped here to match the Web's per-request scope. +builder.Services.AddSharpClientViewModels(ServiceLifetime.Scoped); var app = builder.Build();