refacto static extension for Service provider

This commit is contained in:
Paul Schneider 2026-08-23 23:07:40 +01:00
commit e007c7d6eb
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
13 changed files with 173 additions and 158 deletions

2
.vscode/launch.json vendored
View file

@ -48,7 +48,7 @@
"name": "Test PostIt.Android launch (Xamarin.UITest)", "name": "Test PostIt.Android launch (Xamarin.UITest)",
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net11.0/PostIt.Tests.dll", "program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll",
"args": [ "args": [
], ],
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests", "cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests",

View file

@ -161,9 +161,9 @@ ADB_SERIAL ?= emulator-5554
ANDROID_HOME ?= /opt/android-sdk ANDROID_HOME ?= /opt/android-sdk
POSTIT_RID ?= android-x64 POSTIT_RID ?= android-x64
EMU_HEADLESS ?= 0 EMU_HEADLESS ?= 0
LOGCAT_LINES ?= 200 LOGCAT_LINES ?= 600
LOGCAT_FOLLOW ?= 0 LOGCAT_FOLLOW ?= 0
LOGCAT_BOOT_WAIT ?= 30 LOGCAT_BOOT_WAIT ?= 20
ANDROID_PACKAGE_NAME = fr.pschneider.PostIt ANDROID_PACKAGE_NAME = fr.pschneider.PostIt
POSTIT_ANDROID_CSPROJ := src/PostIt/PostIt.Android/PostIt.Android.csproj POSTIT_ANDROID_CSPROJ := src/PostIt/PostIt.Android/PostIt.Android.csproj
@ -249,9 +249,9 @@ qemu-logcat:
fi; \ fi; \
echo " Following PID $$PID (LOGCAT_FOLLOW=$(LOGCAT_FOLLOW), LOGCAT_LINES=$(LOGCAT_LINES))"; \ echo " Following PID $$PID (LOGCAT_FOLLOW=$(LOGCAT_FOLLOW), LOGCAT_LINES=$(LOGCAT_LINES))"; \
if [ "$(LOGCAT_FOLLOW)" = "1" ]; then \ if [ "$(LOGCAT_FOLLOW)" = "1" ]; then \
adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME):F; \ adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME); \
else \ else \
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME):F; \ adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME); \
fi fi
# Clear logcat, launch PostIt.Android, then dump everything that was # Clear logcat, launch PostIt.Android, then dump everything that was
@ -270,14 +270,16 @@ qemu-logcat-boot:
-n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity
@echo " Waiting $(LOGCAT_BOOT_WAIT)s for the app to start rendering..." @echo " Waiting $(LOGCAT_BOOT_WAIT)s for the app to start rendering..."
@sleep $(LOGCAT_BOOT_WAIT) @sleep $(LOGCAT_BOOT_WAIT)
@echo " Dumping logcat (PostIt PID + system buffer):" @echo " Dumping logcat (PostIt PID + system buffer):"
@PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ @PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \
if [ -n "$$PID" ]; then \ if [ -n "$$PID" ]; then \
echo " (PID $$PID at dump time)"; \ echo " (PID $$PID at dump time)"; \
adb -s $(ADB_SERIAL) logcat -d -v time --pid=$$PID; \ adb -s $(ADB_SERIAL) logcat -d -v time --pid=$$PID; \
else \ else \
echo " (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \ echo " 👿 (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES); \ adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES); \
exit 1; \
fi fi
qemu: qemu-run qemu-wait-boot qemu-install qemu: qemu-run qemu-wait-boot qemu-install

View file

@ -9,6 +9,8 @@
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion> <ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat> <AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot> <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AndroidLinkMode>SdkOnly</AndroidLinkMode>
<TrimMode>partial</TrimMode>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -2,6 +2,7 @@
using Avalonia; using Avalonia;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;

View file

@ -4,6 +4,7 @@ using System.Text.Json;
using Avalonia; using Avalonia;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;

View file

@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net11.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
@ -16,7 +16,9 @@
<PackageReference Include="Xamarin.UITest" /> <PackageReference Include="Xamarin.UITest" />
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
<PackageReference Include="Avalonia.Headless" /> <PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Headless.XUnit" /> <PackageReference Include="Avalonia.Headless.XUnit" />
</ItemGroup> </ItemGroup>
@ -27,7 +29,5 @@
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Headless.XUnit" />
<PackageReference Include="Xamarin.UITest" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -10,7 +10,7 @@ using Microsoft.Extensions.DependencyInjection;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
using Yavsc.Api.Client; using PostIt.Helpers;
namespace PostIt; namespace PostIt;
@ -28,7 +28,7 @@ public partial class App : Application
/// </summary> /// </summary>
public IServiceProvider? ServiceProvider { get; private set; } public IServiceProvider? ServiceProvider { get; private set; }
MainWindow window; public MainWindow? Window { get; private set; }
public override void Initialize() public override void Initialize()
{ {
@ -42,119 +42,58 @@ public partial class App : Application
{ {
if (TryHandOffCustomSchemeUrl()) return; if (TryHandOffCustomSchemeUrl()) return;
this.ServiceProvider = BuildServices(new ServiceCollection()); this.ServiceProvider = new ServiceCollection().BuildServices();
var settings = ServiceProvider.GetRequiredService<Settings>(); var settings = ServiceProvider.GetRequiredService<Settings>();
DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(ServiceProvider));
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
desktop.MainWindow = CreateMainWindow(); desktop.MainWindow = CreateMainWindow();
ApplyDarkMode(settings);
} }
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{ {
singleViewFactoryApplicationLifetime.MainViewFactory = singleViewFactoryApplicationLifetime.MainViewFactory =
() => CreateMainWindow(); () =>
{
Window = CreateMainWindow();
ApplyDarkMode(settings);
return Window;
};
} }
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{ {
singleViewPlatform.MainView = CreateMainWindow(); singleViewPlatform.MainView = CreateMainWindow();
ApplyDarkMode(settings);
} }
ApplyDarkMode(settings);
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
internal static IServiceProvider BuildServices(ServiceCollection services)
{
var settings = new Settings();
settings.Load();
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient);
// Vues
services.AddTransient<MainPage>();
// SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
// its DataContext wired up at composition-root time (see
// below), and (c) PushPageAsync's anti-empilement guard sees
// the same instance across pushes, so a second Settings tap
// is a no-op rather than re-pushing the page. Transient would
// let the user accumulate stale SettingsPage instances on
// the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or
// AddCircleMemberDialogViewModel via App.PushPageAsync.
// App.PushPageAsync overwrites the page's DataContext with
// the caller-built VM, so the parameterless ctor is enough
// here — the parametrised ctors stay for direct test wiring.
services.AddTransient<PostAclDialog>();
services.AddTransient<AddCircleMemberDialog>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.
var sessionStatus = new SessionStatusViewModel { Api = api };
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddTransient<SessionStatusBanner>();
return services.BuildServiceProvider();
}
private MainWindow CreateMainWindow() private MainWindow CreateMainWindow()
{ {
window = new MainWindow(); Window = new MainWindow();
var api = ServiceProvider!.GetRequiredService<YavscApiClient>(); var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api); Window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api);
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>(); var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () => sessionStatus.LogoutCompleted += () =>
{ {
window.NavRoot.PopToRootAsync(); Window.NavRoot.PopToRootAsync();
}; };
sessionStatus.LoginSucceeded += () => sessionStatus.LoginSucceeded += () =>
{ {
PushMainPageAsync(); PushMainPageAsync().Wait();
}; };
var homeVm = ServiceProvider!.GetRequiredService<HomePageViewModel>(); var homeVm = ServiceProvider!.GetRequiredService<HomePageViewModel>();
this.PushPageAsync(homeVm).Wait(); this.PushPageAsync(homeVm).Wait();
window.SessionBanner.DataContext = sessionStatus; Window.SessionBanner.DataContext = sessionStatus;
return window; return Window;
} }
/// <summary> /// <summary>
/// Test-only hook: bind a concrete <see cref="MainWindow"/> so /// Test-only hook: bind a concrete <see cref="MainWindow"/> so
/// command-driven navigation paths (<see cref="PushPage"/>) can /// command-driven navigation paths (<see cref="PushPage"/>) can
/// push onto a real <see cref="NavigationPage"/> in headless /// push onto a real <see cref="NavigationPage"/> in headless
@ -162,7 +101,7 @@ public partial class App : Application
/// </summary> /// </summary>
internal void AttachMainWindow(MainWindow mainWindow) internal void AttachMainWindow(MainWindow mainWindow)
{ {
window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow)); Window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow));
} }
private static void ApplyDarkMode(Settings settings) private static void ApplyDarkMode(Settings settings)
@ -199,11 +138,11 @@ public partial class App : Application
/// (interactive login from the banner). Pulled out as a helper so /// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart. /// the two callers can't drift apart.
/// </summary> /// </summary>
public static Task PushMainPageAsync() public static async Task PushMainPageAsync()
{ {
var app = (App)Current!; var app = (App)Current!;
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>(); var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
return app.PushPageAsync(mainVm); await app.PushPageAsync(mainVm);
} }
private bool TryHandOffCustomSchemeUrl() private bool TryHandOffCustomSchemeUrl()
@ -238,53 +177,8 @@ public partial class App : Application
return true; return true;
} }
internal void PushPage(ViewModelBase vm)
{
_ = PushPageAsync(vm);
}
internal async Task PushPageAsync(ViewModelBase vm)
{
if (window is null)
{
throw new InvalidOperationException("MainWindow is not initialized yet.");
}
var template = DataTemplates.FirstOrDefault(t => t.Match(vm));
if (template is null)
{
throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}.");
}
var view = template.Build(vm);
if (view is null)
{
throw new InvalidOperationException(
$"Template for {vm.GetType().Name} returned <null>.");
}
var page = view as Page;
if (page is null)
{
// NavigationPage expects Page instances. Wrap any fallback control
// (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
page = new ContentPage { Content = view };
}
page.DataContext = vm;
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
var stack = window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return;
}
await window.NavRoot.PushAsync(page);
}
internal async Task GoBackAsync() internal async Task GoBackAsync()
{ {
await window.NavRoot.PopAsync(); await Window!.NavRoot.PopAsync();
} }
} }

View file

@ -0,0 +1,77 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Helpers;
public static class ServiceCollectionHelpers
{
public static IServiceProvider BuildServices(this ServiceCollection services)
{
var settings = new Settings();
settings.Load();
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient);
// Vues
services.AddTransient<MainPage>();
// SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
// its DataContext wired up at composition-root time (see
// below), and (c) PushPageAsync's anti-empilement guard sees
// the same instance across pushes, so a second Settings tap
// is a no-op rather than re-pushing the page. Transient would
// let the user accumulate stale SettingsPage instances on
// the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or
// AddCircleMemberDialogViewModel via App.PushPageAsync.
// App.PushPageAsync overwrites the page's DataContext with
// the caller-built VM, so the parameterless ctor is enough
// here — the parametrised ctors stay for direct test wiring.
services.AddTransient<PostAclDialog>();
services.AddTransient<AddCircleMemberDialog>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.
var sessionStatus = new SessionStatusViewModel { Api = api };
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddTransient<SessionStatusBanner>();
return services.BuildServiceProvider();
}
}

View file

@ -0,0 +1,50 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using PostIt.ViewModels;
namespace PostIt.Helpers;
public static class ViewModelBaseHelpers
{
public static async Task PushPageAsync(this App app, ViewModelBase vm)
{
if (app.Window is null)
{
throw new InvalidOperationException("MainWindow is not initialized yet.");
}
var template = app.DataTemplates.FirstOrDefault(t => t.Match(vm));
if (template is null)
{
throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}.");
}
var view = template.Build(vm);
if (view is null)
{
throw new InvalidOperationException(
$"Template for {vm.GetType().Name} returned <null>.");
}
var page = view as Page;
if (page is null)
{
// NavigationPage expects Page instances. Wrap any fallback control
// (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
page = new ContentPage { Content = view };
}
page.DataContext = vm;
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
var stack = app.Window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return;
}
await app.Window.NavRoot.PushAsync(page);
}
}

View file

@ -5,6 +5,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services; using PostIt.Services;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;

View file

@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using PostIt.Helpers;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -3,6 +3,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services; using PostIt.Services;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -139,7 +140,7 @@ public partial class SessionStatusViewModel : ViewModelBase
internal async Task OpenSettings() internal async Task OpenSettings()
{ {
var app = (App)App.Current!; var app = (App)App.Current!;
await app.PushPageAsync(app.ServiceProvider.GetRequiredService<Settings>()).ConfigureAwait(true); await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<Settings>()).ConfigureAwait(true);
} }
} }

View file

@ -47,22 +47,7 @@ public partial class Settings : ViewModelBase
/// </summary> /// </summary>
private static Settings? s_current; private static Settings? s_current;
/// <summary>
/// Wire the canonical Settings instance to a DI container. Called
/// exactly once from <c>App.axaml.cs</c> after the singleton has
/// been registered. Subsequent calls are no-ops: the DI container
/// owns the instance lifetime and we don't want a stray
/// <c>BindToServiceProvider</c> in a test fixture to silently
/// rebind the production instance.
/// </summary>
public static void BindToServiceProvider(IServiceProvider services)
{
if (services is null) throw new ArgumentNullException(nameof(services));
Interlocked.CompareExchange(ref s_current,
services.GetService<Settings>() ?? throw new InvalidOperationException(
"Settings is not registered in the DI container."),
null);
}
[ObservableProperty] [ObservableProperty]
public partial AuthenticationSettings Authentication { get; set; } = new(); public partial AuthenticationSettings Authentication { get; set; } = new();
@ -81,7 +66,7 @@ public partial class Settings : ViewModelBase
/// setters above all funnel through here, and we flip /// setters above all funnel through here, and we flip
/// <see cref="IsDirty"/> in lock-step. Sub-property mutations /// <see cref="IsDirty"/> in lock-step. Sub-property mutations
/// (e.g. <c>Authentication.Authority</c>) are caught by the /// (e.g. <c>Authentication.Authority</c>) are caught by the
/// subscription wired up in <see cref="OnAuthenticationChanged"/> /// subscription wired up in
/// below. <see cref="ApplyJson"/> disables the flag during bulk /// below. <see cref="ApplyJson"/> disables the flag during bulk
/// hydration so the disk load itself does not count as a user /// hydration so the disk load itself does not count as a user
/// edit. /// edit.