From 0912287b2244da8790556623106210192b3b115c Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Mon, 4 Aug 2025 01:35:02 +0100 Subject: [PATCH 01/15] Introduce ReactiveUIBuilder Adds the ReactiveUIBuilder pattern and related platform extension methods for configuring ReactiveUI services without reflection, supporting AOT environments. Includes core builder implementation, platform-specific extension methods (WPF, WinForms, MAUI, WinUI, Blazor, Drawing), interface for custom modules, and comprehensive tests. Updates API approval files to reflect new public APIs and documents usage in ReactiveUI.Builder.md. --- .../ReactiveUIBuilderBlazorExtensions.cs | 27 +++ .../ReactiveUIBuilderDrawingExtensions.cs | 27 +++ .../ReactiveUIBuilderMauiExtensions.cs | 27 +++ ...valTests.ReactiveUI.DotNet8_0.verified.txt | 29 +++ ...valTests.ReactiveUI.DotNet9_0.verified.txt | 29 +++ ...provalTests.ReactiveUI.Net4_7.verified.txt | 21 ++ ...rovalTests.Winforms.DotNet8_0.verified.txt | 4 + ...rovalTests.Winforms.DotNet9_0.verified.txt | 4 + ...ApprovalTests.Winforms.Net4_7.verified.txt | 4 + .../ReactiveUIBuilderWinFormsTests.cs | 58 +++++ ...piApprovalTests.Wpf.DotNet8_0.verified.txt | 4 + ...piApprovalTests.Wpf.DotNet9_0.verified.txt | 4 + ...pfApiApprovalTests.Wpf.Net4_7.verified.txt | 4 + .../wpf/ReactiveUIBuilderWpfTests.cs | 58 +++++ .../ReactiveUIBuilderTests.cs | 221 ++++++++++++++++++ .../ReactiveUIBuilderWinUIExtensions.cs | 27 +++ .../ReactiveUIBuilderWinFormsExtensions.cs | 27 +++ .../ReactiveUIBuilderWpfExtensions.cs | 27 +++ src/ReactiveUI/Builder/IReactiveUIModule.cs | 19 ++ src/ReactiveUI/Builder/ReactiveUIBuilder.cs | 161 +++++++++++++ .../Mixins/ReactiveUIBuilderExtensions.cs | 24 ++ 21 files changed, 806 insertions(+) create mode 100644 src/ReactiveUI.Blazor/ReactiveUIBuilderBlazorExtensions.cs create mode 100644 src/ReactiveUI.Drawing/ReactiveUIBuilderDrawingExtensions.cs create mode 100644 src/ReactiveUI.Maui/ReactiveUIBuilderMauiExtensions.cs create mode 100644 src/ReactiveUI.Tests/Platforms/winforms/ReactiveUIBuilderWinFormsTests.cs create mode 100644 src/ReactiveUI.Tests/Platforms/wpf/ReactiveUIBuilderWpfTests.cs create mode 100644 src/ReactiveUI.Tests/ReactiveUIBuilderTests.cs create mode 100644 src/ReactiveUI.WinUI/ReactiveUIBuilderWinUIExtensions.cs create mode 100644 src/ReactiveUI.Winforms/ReactiveUIBuilderWinFormsExtensions.cs create mode 100644 src/ReactiveUI.Wpf/ReactiveUIBuilderWpfExtensions.cs create mode 100644 src/ReactiveUI/Builder/IReactiveUIModule.cs create mode 100644 src/ReactiveUI/Builder/ReactiveUIBuilder.cs create mode 100644 src/ReactiveUI/Mixins/ReactiveUIBuilderExtensions.cs diff --git a/src/ReactiveUI.Blazor/ReactiveUIBuilderBlazorExtensions.cs b/src/ReactiveUI.Blazor/ReactiveUIBuilderBlazorExtensions.cs new file mode 100644 index 000000000..6483c15df --- /dev/null +++ b/src/ReactiveUI.Blazor/ReactiveUIBuilderBlazorExtensions.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Blazor; + +/// +/// Blazor-specific extensions for ReactiveUIBuilder. +/// +public static class ReactiveUIBuilderBlazorExtensions +{ + /// + /// Registers Blazor-specific services. + /// + /// The builder instance. + /// The builder instance for method chaining. + public static Builder.ReactiveUIBuilder WithBlazor(this Builder.ReactiveUIBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return builder.WithPlatformModule(); + } +} diff --git a/src/ReactiveUI.Drawing/ReactiveUIBuilderDrawingExtensions.cs b/src/ReactiveUI.Drawing/ReactiveUIBuilderDrawingExtensions.cs new file mode 100644 index 000000000..97363ed31 --- /dev/null +++ b/src/ReactiveUI.Drawing/ReactiveUIBuilderDrawingExtensions.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Drawing; + +/// +/// Drawing-specific extensions for ReactiveUIBuilder. +/// +public static class ReactiveUIBuilderDrawingExtensions +{ + /// + /// Registers Drawing-specific services. + /// + /// The builder instance. + /// The builder instance for method chaining. + public static Builder.ReactiveUIBuilder WithDrawing(this Builder.ReactiveUIBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return builder.WithPlatformModule(); + } +} diff --git a/src/ReactiveUI.Maui/ReactiveUIBuilderMauiExtensions.cs b/src/ReactiveUI.Maui/ReactiveUIBuilderMauiExtensions.cs new file mode 100644 index 000000000..9a3d828f7 --- /dev/null +++ b/src/ReactiveUI.Maui/ReactiveUIBuilderMauiExtensions.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Maui; + +/// +/// MAUI-specific extensions for ReactiveUIBuilder. +/// +public static class ReactiveUIBuilderMauiExtensions +{ + /// + /// Registers MAUI-specific services. + /// + /// The builder instance. + /// The builder instance for method chaining. + public static Builder.ReactiveUIBuilder WithMaui(this Builder.ReactiveUIBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return builder.WithPlatformModule(); + } +} diff --git a/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet8_0.verified.txt b/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet8_0.verified.txt index 69fd7a673..d6f562f0c 100644 --- a/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet8_0.verified.txt +++ b/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet8_0.verified.txt @@ -909,6 +909,10 @@ namespace ReactiveUI public System.IDisposable DelayChangeNotifications() { } public System.IDisposable SuppressChangeNotifications() { } } + public static class ReactiveUIBuilderExtensions + { + public static ReactiveUI.Builder.ReactiveUIBuilder CreateBuilder(this Splat.IMutableDependencyResolver resolver) { } + } [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] public static class Reflection @@ -1255,3 +1259,28 @@ namespace ReactiveUI where TSender : class { } } } +namespace ReactiveUI.Builder +{ + public interface IReactiveUIModule + { + void Configure(Splat.IMutableDependencyResolver resolver); + } + public sealed class ReactiveUIBuilder + { + public ReactiveUIBuilder(Splat.IMutableDependencyResolver resolver) { } + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] + public void Build() { } + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] + public ReactiveUI.Builder.ReactiveUIBuilder WithCoreServices() { } + public ReactiveUI.Builder.ReactiveUIBuilder WithCustomRegistration(System.Action configureAction) { } + public ReactiveUI.Builder.ReactiveUIBuilder WithModule(ReactiveUI.Builder.IReactiveUIModule registrationModule) { } + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] + public ReactiveUI.Builder.ReactiveUIBuilder WithPlatformServices() { } + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] + public ReactiveUI.Builder.ReactiveUIBuilder WithViewsFromAssembly(System.Reflection.Assembly assembly) { } + } +} \ No newline at end of file diff --git a/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet9_0.verified.txt b/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet9_0.verified.txt index 987b5c033..3dfcd5fca 100644 --- a/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet9_0.verified.txt +++ b/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.DotNet9_0.verified.txt @@ -909,6 +909,10 @@ namespace ReactiveUI public System.IDisposable DelayChangeNotifications() { } public System.IDisposable SuppressChangeNotifications() { } } + public static class ReactiveUIBuilderExtensions + { + public static ReactiveUI.Builder.ReactiveUIBuilder CreateBuilder(this Splat.IMutableDependencyResolver resolver) { } + } [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] public static class Reflection @@ -1255,3 +1259,28 @@ namespace ReactiveUI where TSender : class { } } } +namespace ReactiveUI.Builder +{ + public interface IReactiveUIModule + { + void Configure(Splat.IMutableDependencyResolver resolver); + } + public sealed class ReactiveUIBuilder + { + public ReactiveUIBuilder(Splat.IMutableDependencyResolver resolver) { } + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] + public void Build() { } + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] + public ReactiveUI.Builder.ReactiveUIBuilder WithCoreServices() { } + public ReactiveUI.Builder.ReactiveUIBuilder WithCustomRegistration(System.Action configureAction) { } + public ReactiveUI.Builder.ReactiveUIBuilder WithModule(ReactiveUI.Builder.IReactiveUIModule registrationModule) { } + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] + public ReactiveUI.Builder.ReactiveUIBuilder WithPlatformServices() { } + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] + public ReactiveUI.Builder.ReactiveUIBuilder WithViewsFromAssembly(System.Reflection.Assembly assembly) { } + } +} \ No newline at end of file diff --git a/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.Net4_7.verified.txt b/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.Net4_7.verified.txt index 3e83876a4..a8295ae08 100644 --- a/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.Net4_7.verified.txt +++ b/src/ReactiveUI.Tests/API/ApiApprovalTests.ReactiveUI.Net4_7.verified.txt @@ -817,6 +817,10 @@ namespace ReactiveUI public System.IDisposable DelayChangeNotifications() { } public System.IDisposable SuppressChangeNotifications() { } } + public static class ReactiveUIBuilderExtensions + { + public static ReactiveUI.Builder.ReactiveUIBuilder CreateBuilder(this Splat.IMutableDependencyResolver resolver) { } + } public static class Reflection { public static string ExpressionToPropertyNames(System.Linq.Expressions.Expression? expression) { } @@ -1148,3 +1152,20 @@ namespace ReactiveUI where TSender : class { } } } +namespace ReactiveUI.Builder +{ + public interface IReactiveUIModule + { + void Configure(Splat.IMutableDependencyResolver resolver); + } + public sealed class ReactiveUIBuilder + { + public ReactiveUIBuilder(Splat.IMutableDependencyResolver resolver) { } + public void Build() { } + public ReactiveUI.Builder.ReactiveUIBuilder WithCoreServices() { } + public ReactiveUI.Builder.ReactiveUIBuilder WithCustomRegistration(System.Action configureAction) { } + public ReactiveUI.Builder.ReactiveUIBuilder WithModule(ReactiveUI.Builder.IReactiveUIModule registrationModule) { } + public ReactiveUI.Builder.ReactiveUIBuilder WithPlatformServices() { } + public ReactiveUI.Builder.ReactiveUIBuilder WithViewsFromAssembly(System.Reflection.Assembly assembly) { } + } +} \ No newline at end of file diff --git a/src/ReactiveUI.Tests/Platforms/winforms/API/WinformsApiApprovalTests.Winforms.DotNet8_0.verified.txt b/src/ReactiveUI.Tests/Platforms/winforms/API/WinformsApiApprovalTests.Winforms.DotNet8_0.verified.txt index 30a2bcbcd..c8c6d511d 100644 --- a/src/ReactiveUI.Tests/Platforms/winforms/API/WinformsApiApprovalTests.Winforms.DotNet8_0.verified.txt +++ b/src/ReactiveUI.Tests/Platforms/winforms/API/WinformsApiApprovalTests.Winforms.DotNet8_0.verified.txt @@ -32,6 +32,10 @@ namespace ReactiveUI.Winforms public PlatformOperations() { } public string? GetOrientation() { } } + public static class ReactiveUIBuilderWinFormsExtensions + { + public static ReactiveUI.Builder.ReactiveUIBuilder WithWinForms(this ReactiveUI.Builder.ReactiveUIBuilder builder) { } + } public class ReactiveUserControlNonGeneric : System.Windows.Forms.UserControl, ReactiveUI.IActivatableView, ReactiveUI.IViewFor { public ReactiveUserControlNonGeneric() { } diff --git a/src/ReactiveUI.Tests/Platforms/winforms/API/WinformsApiApprovalTests.Winforms.DotNet9_0.verified.txt b/src/ReactiveUI.Tests/Platforms/winforms/API/WinformsApiApprovalTests.Winforms.DotNet9_0.verified.txt index 0602a8f61..15684c154 100644 --- a/src/ReactiveUI.Tests/Platforms/winforms/API/WinformsApiApprovalTests.Winforms.DotNet9_0.verified.txt +++ b/src/ReactiveUI.Tests/Platforms/winforms/API/WinformsApiApprovalTests.Winforms.DotNet9_0.verified.txt @@ -32,6 +32,10 @@ namespace ReactiveUI.Winforms public PlatformOperations() { } public string? GetOrientation() { } } + public static class ReactiveUIBuilderWinFormsExtensions + { + public static ReactiveUI.Builder.ReactiveUIBuilder WithWinForms(this ReactiveUI.Builder.ReactiveUIBuilder builder) { } + } public class ReactiveUserControlNonGeneric : System.Windows.Forms.UserControl, ReactiveUI.IActivatableView, ReactiveUI.IViewFor { public ReactiveUserControlNonGeneric() { } diff --git a/src/ReactiveUI.Tests/Platforms/winforms/API/WinformsApiApprovalTests.Winforms.Net4_7.verified.txt b/src/ReactiveUI.Tests/Platforms/winforms/API/WinformsApiApprovalTests.Winforms.Net4_7.verified.txt index 675c50cb1..c2573ef94 100644 --- a/src/ReactiveUI.Tests/Platforms/winforms/API/WinformsApiApprovalTests.Winforms.Net4_7.verified.txt +++ b/src/ReactiveUI.Tests/Platforms/winforms/API/WinformsApiApprovalTests.Winforms.Net4_7.verified.txt @@ -30,6 +30,10 @@ namespace ReactiveUI.Winforms public PlatformOperations() { } public string? GetOrientation() { } } + public static class ReactiveUIBuilderWinFormsExtensions + { + public static ReactiveUI.Builder.ReactiveUIBuilder WithWinForms(this ReactiveUI.Builder.ReactiveUIBuilder builder) { } + } public class ReactiveUserControlNonGeneric : System.Windows.Forms.UserControl, ReactiveUI.IActivatableView, ReactiveUI.IViewFor { public ReactiveUserControlNonGeneric() { } diff --git a/src/ReactiveUI.Tests/Platforms/winforms/ReactiveUIBuilderWinFormsTests.cs b/src/ReactiveUI.Tests/Platforms/winforms/ReactiveUIBuilderWinFormsTests.cs new file mode 100644 index 000000000..0c16637b1 --- /dev/null +++ b/src/ReactiveUI.Tests/Platforms/winforms/ReactiveUIBuilderWinFormsTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Winforms; + +namespace ReactiveUI.Tests.Platforms.Winforms; + +/// +/// Tests for WinForms-specific ReactiveUIBuilder functionality. +/// +public class ReactiveUIBuilderWinFormsTests +{ + /// + /// Test that WinForms services can be registered using the builder. + /// + [Fact] + public void WithWinForms_Should_Register_WinForms_Services() + { + // Arrange + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + // Act + builder.WithWinForms().Build(); + + // Assert + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + + var activationFetcher = locator.GetService(); + Assert.NotNull(activationFetcher); + } + + /// + /// Test that the builder can chain WinForms registration with core services. + /// + [Fact] + public void WithCoreServices_AndWinForms_Should_Register_All_Services() + { + // Arrange + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + // Act + builder.WithCoreServices().WithWinForms().Build(); + + // Assert + // Core services + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + // WinForms-specific services + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + } +} diff --git a/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet8_0.verified.txt b/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet8_0.verified.txt index 54868b4e3..71c9ccc25 100644 --- a/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet8_0.verified.txt +++ b/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet8_0.verified.txt @@ -140,6 +140,10 @@ namespace ReactiveUI } namespace ReactiveUI.Wpf { + public static class ReactiveUIBuilderWpfExtensions + { + public static ReactiveUI.Builder.ReactiveUIBuilder WithWpf(this ReactiveUI.Builder.ReactiveUIBuilder builder) { } + } public class Registrations { public Registrations() { } diff --git a/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet9_0.verified.txt b/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet9_0.verified.txt index 0a94f8e9e..b37e3e026 100644 --- a/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet9_0.verified.txt +++ b/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.DotNet9_0.verified.txt @@ -140,6 +140,10 @@ namespace ReactiveUI } namespace ReactiveUI.Wpf { + public static class ReactiveUIBuilderWpfExtensions + { + public static ReactiveUI.Builder.ReactiveUIBuilder WithWpf(this ReactiveUI.Builder.ReactiveUIBuilder builder) { } + } public class Registrations { public Registrations() { } diff --git a/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.Net4_7.verified.txt b/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.Net4_7.verified.txt index 5907b295b..86313dba9 100644 --- a/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.Net4_7.verified.txt +++ b/src/ReactiveUI.Tests/Platforms/wpf/API/WpfApiApprovalTests.Wpf.Net4_7.verified.txt @@ -138,6 +138,10 @@ namespace ReactiveUI } namespace ReactiveUI.Wpf { + public static class ReactiveUIBuilderWpfExtensions + { + public static ReactiveUI.Builder.ReactiveUIBuilder WithWpf(this ReactiveUI.Builder.ReactiveUIBuilder builder) { } + } public class Registrations { public Registrations() { } diff --git a/src/ReactiveUI.Tests/Platforms/wpf/ReactiveUIBuilderWpfTests.cs b/src/ReactiveUI.Tests/Platforms/wpf/ReactiveUIBuilderWpfTests.cs new file mode 100644 index 000000000..5614cfb25 --- /dev/null +++ b/src/ReactiveUI.Tests/Platforms/wpf/ReactiveUIBuilderWpfTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Wpf; + +namespace ReactiveUI.Tests.Platforms.Wpf; + +/// +/// Tests for WPF-specific ReactiveUIBuilder functionality. +/// +public class ReactiveUIBuilderWpfTests +{ + /// + /// Test that WPF services can be registered using the builder. + /// + [Fact] + public void WithWpf_Should_Register_Wpf_Services() + { + // Arrange + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + // Act + builder.WithWpf().Build(); + + // Assert + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + + var activationFetcher = locator.GetService(); + Assert.NotNull(activationFetcher); + } + + /// + /// Test that the builder can chain WPF registration with core services. + /// + [Fact] + public void WithCoreServices_AndWpf_Should_Register_All_Services() + { + // Arrange + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + // Act + builder.WithCoreServices().WithWpf().Build(); + + // Assert + // Core services + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + // WPF-specific services + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + } +} diff --git a/src/ReactiveUI.Tests/ReactiveUIBuilderTests.cs b/src/ReactiveUI.Tests/ReactiveUIBuilderTests.cs new file mode 100644 index 000000000..be8d2be94 --- /dev/null +++ b/src/ReactiveUI.Tests/ReactiveUIBuilderTests.cs @@ -0,0 +1,221 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Builder; + +namespace ReactiveUI.Tests; + +/// +/// Tests for the ReactiveUIBuilder core functionality. +/// +public class ReactiveUIBuilderTests +{ + /// + /// Test that the builder can be created from a dependency resolver. + /// + [Fact] + public void CreateBuilder_Should_Return_Builder_Instance() + { + // Arrange + using var locator = new ModernDependencyResolver(); + + // Act + var builder = locator.CreateBuilder(); + + // Assert + Assert.NotNull(builder); + Assert.IsType(builder); + } + + /// + /// Test that core services are registered when using the builder. + /// + [Fact] + public void WithCoreServices_Should_Register_Core_Services() + { + // Arrange + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + // Act + builder.WithCoreServices().Build(); + + // Assert + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + var typeConverter = locator.GetService(); + Assert.NotNull(typeConverter); + } + + /// + /// Test that platform services are registered when using the builder. + /// + [Fact] + public void WithPlatformServices_Should_Register_Platform_Services() + { + // Arrange + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + // Act + builder.WithPlatformServices().Build(); + + // Assert + // Platform services vary by platform, so we check what's available + var services = locator.GetServices(); + Assert.NotNull(services); + Assert.True(services.Any()); + } + + /// + /// Test that custom registration actions work. + /// + [Fact] + public void WithCustomRegistration_Should_Execute_Custom_Action() + { + // Arrange + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + var customServiceRegistered = false; + + // Act + builder.WithCustomRegistration(r => + { + r.RegisterConstant("TestValue", typeof(string)); + customServiceRegistered = true; + }).Build(); + + // Assert + Assert.True(customServiceRegistered); + var service = locator.GetService(); + Assert.Equal("TestValue", service); + } + + /// + /// Test that builder ensures core services are always registered. + /// + [Fact] + public void Build_Should_Always_Register_Core_Services() + { + // Arrange + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + // Act - Build without explicitly calling WithCoreServices + builder.Build(); + + // Assert + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + } + + /// + /// Test that null resolver throws exception. + /// + [Fact] + public void Constructor_With_Null_Resolver_Should_Throw() + { + // Act & Assert + Assert.Throws(() => new ReactiveUIBuilder(null!)); + } + + /// + /// Test that null custom registration throws exception. + /// + [Fact] + public void WithCustomRegistration_With_Null_Action_Should_Throw() + { + // Arrange + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + // Act & Assert + Assert.Throws(() => builder.WithCustomRegistration(null!)); + } + + /// + /// Test that WithViewsFromAssembly works correctly. + /// + [Fact] + public void WithViewsFromAssembly_Should_Register_Views() + { + // Arrange + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + var assembly = typeof(ReactiveUIBuilderTests).Assembly; + + // Act + builder.WithViewsFromAssembly(assembly).Build(); + + // Assert - Should not throw any exceptions + Assert.NotNull(builder); + } + + /// + /// Test that null assembly throws exception. + /// + [Fact] + public void WithViewsFromAssembly_With_Null_Assembly_Should_Throw() + { + // Arrange + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + // Act & Assert + Assert.Throws(() => builder.WithViewsFromAssembly(null!)); + } + + /// + /// Test that multiple calls to WithCoreServices don't register services twice. + /// + [Fact] + public void WithCoreServices_Called_Multiple_Times_Should_Not_Register_Twice() + { + // Arrange + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + // Act + builder.WithCoreServices().WithCoreServices().Build(); + + // Assert + // Verify that services are registered but not duplicated + var services = locator.GetServices(); + Assert.NotNull(services); + Assert.True(services.Any()); + } + + /// + /// Test builder with fluent chaining. + /// + [Fact] + public void Builder_Should_Support_Fluent_Chaining() + { + // Arrange + using var locator = new ModernDependencyResolver(); + var customServiceRegistered = false; + + // Act + locator.CreateBuilder() + .WithCoreServices() + .WithPlatformServices() + .WithCustomRegistration(r => + { + r.RegisterConstant("Test", typeof(string)); + customServiceRegistered = true; + }) + .Build(); + + // Assert + Assert.True(customServiceRegistered); + var service = locator.GetService(); + Assert.Equal("Test", service); + + // Verify core services are registered + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + } +} diff --git a/src/ReactiveUI.WinUI/ReactiveUIBuilderWinUIExtensions.cs b/src/ReactiveUI.WinUI/ReactiveUIBuilderWinUIExtensions.cs new file mode 100644 index 000000000..86f63e5ef --- /dev/null +++ b/src/ReactiveUI.WinUI/ReactiveUIBuilderWinUIExtensions.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.WinUI; + +/// +/// WinUI-specific extensions for ReactiveUIBuilder. +/// +public static class ReactiveUIBuilderWinUIExtensions +{ + /// + /// Registers WinUI-specific services. + /// + /// The builder instance. + /// The builder instance for method chaining. + public static Builder.ReactiveUIBuilder WithWinUI(this Builder.ReactiveUIBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return builder.WithPlatformModule(); + } +} diff --git a/src/ReactiveUI.Winforms/ReactiveUIBuilderWinFormsExtensions.cs b/src/ReactiveUI.Winforms/ReactiveUIBuilderWinFormsExtensions.cs new file mode 100644 index 000000000..c5e7e2599 --- /dev/null +++ b/src/ReactiveUI.Winforms/ReactiveUIBuilderWinFormsExtensions.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Winforms; + +/// +/// WinForms-specific extensions for ReactiveUIBuilder. +/// +public static class ReactiveUIBuilderWinFormsExtensions +{ + /// + /// Registers WinForms-specific services. + /// + /// The builder instance. + /// The builder instance for method chaining. + public static Builder.ReactiveUIBuilder WithWinForms(this Builder.ReactiveUIBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return builder.WithPlatformModule(); + } +} diff --git a/src/ReactiveUI.Wpf/ReactiveUIBuilderWpfExtensions.cs b/src/ReactiveUI.Wpf/ReactiveUIBuilderWpfExtensions.cs new file mode 100644 index 000000000..56e35c6b6 --- /dev/null +++ b/src/ReactiveUI.Wpf/ReactiveUIBuilderWpfExtensions.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Wpf; + +/// +/// WPF-specific extensions for ReactiveUIBuilder. +/// +public static class ReactiveUIBuilderWpfExtensions +{ + /// + /// Registers WPF-specific services. + /// + /// The builder instance. + /// The builder instance for method chaining. + public static Builder.ReactiveUIBuilder WithWpf(this Builder.ReactiveUIBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return builder.WithPlatformModule(); + } +} diff --git a/src/ReactiveUI/Builder/IReactiveUIModule.cs b/src/ReactiveUI/Builder/IReactiveUIModule.cs new file mode 100644 index 000000000..b63acc462 --- /dev/null +++ b/src/ReactiveUI/Builder/IReactiveUIModule.cs @@ -0,0 +1,19 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder; + +/// +/// Defines a contract for ReactiveUI modules that can configure dependency injection. +/// This provides an AOT-compatible way to register services. +/// +public interface IReactiveUIModule +{ + /// + /// Configures the dependency resolver with the module's services. + /// + /// The dependency resolver to configure. + void Configure(IMutableDependencyResolver resolver); +} diff --git a/src/ReactiveUI/Builder/ReactiveUIBuilder.cs b/src/ReactiveUI/Builder/ReactiveUIBuilder.cs new file mode 100644 index 000000000..046757c19 --- /dev/null +++ b/src/ReactiveUI/Builder/ReactiveUIBuilder.cs @@ -0,0 +1,161 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reflection; + +namespace ReactiveUI.Builder; + +/// +/// A builder class for configuring ReactiveUI without using reflection. +/// This provides an AOT-compatible alternative to the reflection-based InitializeReactiveUI method. +/// +public sealed class ReactiveUIBuilder +{ + private readonly IMutableDependencyResolver _resolver; + private readonly List> _registrations = []; + private bool _coreRegistered; + private bool _platformRegistered; + + /// + /// Initializes a new instance of the class. + /// + /// The dependency resolver to configure. + public ReactiveUIBuilder(IMutableDependencyResolver resolver) => _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + + /// + /// Registers the core ReactiveUI services. + /// + /// The builder instance for method chaining. +#if NET6_0_OR_GREATER + [RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] + [RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] +#endif + public ReactiveUIBuilder WithCoreServices() + { + if (_coreRegistered) + { + return this; + } + + _registrations.Add(resolver => + { + var registrations = new Registrations(); + registrations.Register((f, t) => resolver.RegisterConstant(f(), t)); + }); + + _coreRegistered = true; + return this; + } + + /// + /// Registers the platform-specific ReactiveUI services. + /// + /// The builder instance for method chaining. +#if NET6_0_OR_GREATER + [RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] + [RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] +#endif + public ReactiveUIBuilder WithPlatformServices() + { + if (_platformRegistered) + { + return this; + } + + _registrations.Add(resolver => + { + var platformRegistrations = new PlatformRegistrations(); + platformRegistrations.Register((f, t) => resolver.RegisterConstant(f(), t)); + }); + + _platformRegistered = true; + return this; + } + + /// + /// Registers a custom registration module. + /// + /// The registration module to add. + /// The builder instance for method chaining. + public ReactiveUIBuilder WithModule(IReactiveUIModule registrationModule) + { + registrationModule.ArgumentNullExceptionThrowIfNull(nameof(registrationModule)); + + _registrations.Add(resolver => registrationModule.Configure(resolver)); + return this; + } + + /// + /// Registers a custom registration action. + /// + /// The configuration action to add. + /// The builder instance for method chaining. + public ReactiveUIBuilder WithCustomRegistration(Action configureAction) + { + configureAction.ArgumentNullExceptionThrowIfNull(nameof(configureAction)); + + _registrations.Add(configureAction); + return this; + } + + /// + /// Automatically registers all views that implement IViewFor from the specified assembly. + /// + /// The assembly to scan for views. + /// The builder instance for method chaining. +#if NET6_0_OR_GREATER + [RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] + [RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] +#endif + public ReactiveUIBuilder WithViewsFromAssembly(Assembly assembly) + { + assembly.ArgumentNullExceptionThrowIfNull(nameof(assembly)); + + _registrations.Add(resolver => resolver.RegisterViewsForViewModels(assembly)); + return this; + } + + /// + /// Builds and applies all registrations to the dependency resolver. + /// +#if NET6_0_OR_GREATER + [RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] + [RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] +#endif + public void Build() + { + // Ensure core services are always registered + if (!_coreRegistered) + { + WithCoreServices(); + } + + // Apply all registrations + foreach (var registration in _registrations) + { + registration(_resolver); + } + } + + /// + /// Registers a platform-specific registration module by type. + /// + /// The type of the registration module that implements IWantsToRegisterStuff. + /// The builder instance for method chaining. +#if NET6_0_OR_GREATER + [RequiresDynamicCode("The method uses reflection and will not work in AOT environments.")] + [RequiresUnreferencedCode("The method uses reflection and will not work in AOT environments.")] +#endif + internal ReactiveUIBuilder WithPlatformModule() + where T : IWantsToRegisterStuff, new() + { + _registrations.Add(resolver => + { + var registration = new T(); + registration.Register((f, t) => resolver.RegisterConstant(f(), t)); + }); + return this; + } +} diff --git a/src/ReactiveUI/Mixins/ReactiveUIBuilderExtensions.cs b/src/ReactiveUI/Mixins/ReactiveUIBuilderExtensions.cs new file mode 100644 index 000000000..1137d9f79 --- /dev/null +++ b/src/ReactiveUI/Mixins/ReactiveUIBuilderExtensions.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI; + +/// +/// Extension methods for ReactiveUI Builder functionality. +/// +public static class ReactiveUIBuilderExtensions +{ + /// + /// Creates a builder for configuring ReactiveUI without using reflection. + /// This provides an AOT-compatible alternative to the reflection-based InitializeReactiveUI method. + /// + /// The dependency resolver to configure. + /// A ReactiveUIBuilder instance for fluent configuration. + public static Builder.ReactiveUIBuilder CreateBuilder(this IMutableDependencyResolver resolver) + { + resolver.ArgumentNullExceptionThrowIfNull(nameof(resolver)); + return new Builder.ReactiveUIBuilder(resolver); + } +} From 71fc78c29cedf713bd7ce1b9a5fc0bd6639cb64b Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Wed, 13 Aug 2025 23:17:06 +0100 Subject: [PATCH 02/15] Extend the Builder pattern to ensure that all frameworks are covered Include tests for the Builder functionality --- .../ReactiveUIBuilderAndroidXExtensions.cs | 31 ++++++ src/ReactiveUI.AndroidX/Registrations.cs | 45 ++++++++ .../ReactiveUIBuilderBlazorExtensions.cs | 4 + .../ReactiveUI.Builder.AndroidX.Tests.csproj | 15 +++ .../ReactiveUIBuilderAndroidXTests.cs | 44 ++++++++ .../ReactiveUI.Builder.Maui.Tests.csproj | 16 +++ .../ReactiveUIBuilderMauiTests.cs | 41 +++++++ .../Blazor/ReactiveUIBuilderBlazorTests.cs | 41 +++++++ .../Drawing/ReactiveUIBuilderDrawingTests.cs | 25 +++++ .../ReactiveUIBuilderWinFormsTests.cs | 41 +++++++ .../Wpf/ReactiveUIBuilderWpfTests.cs | 41 +++++++ .../ReactiveUI.Builder.Tests.csproj | 31 ++++++ .../ReactiveUIBuilderBlockingTests.cs | 35 ++++++ .../ReactiveUIBuilderCoreTests.cs} | 104 ++---------------- .../ReactiveUIBuilderDrawingExtensions.cs | 6 + .../ReactiveUIBuilderMauiExtensions.cs | 4 + .../Locator/Mocks/BarViewModel.cs | 4 +- .../Locator/Mocks/FooViewModel.cs | 4 +- .../Mocks/FooViewModelWithWeirdName.cs | 4 +- .../Locator/Mocks/IBarViewModel.cs | 4 +- .../Locator/Mocks/IFooView.cs | 4 +- .../Locator/Mocks/IFooViewModel.cs | 4 +- .../Locator/Mocks/IRoutableFooViewModel.cs | 4 +- ...IStrangeInterfaceNotFollowingConvention.cs | 4 +- .../StrangeClassNotFollowingConvention.cs | 4 +- .../Mocks/AnotherViewModel.cs | 4 +- .../Mocks/ExampleViewModel.cs | 4 +- .../Mocks/ExampleWindowViewModel.cs | 4 +- .../Mocks/NeverUsedViewModel.cs | 4 +- .../Mocks/SingleInstanceExampleViewModel.cs | 4 +- .../Mocks/ViewModelWithWeirdName.cs | 4 +- .../ReactiveUIBuilderAndroidXTests.cs | 53 +++++++++ .../Platforms/winforms/Mocks/AnotherView.cs | 4 +- .../winforms/Mocks/ContractExampleView.cs | 4 +- .../Platforms/winforms/Mocks/ExampleView.cs | 4 +- .../Platforms/winforms/Mocks/TestControl.cs | 4 +- .../winforms/Mocks/TestFormNotCanActivate.cs | 4 +- .../winforms/Mocks/ViewWithoutMatchingName.cs | 4 +- .../Platforms/wpf/Mocks/ExampleWindowView.cs | 4 +- .../Mocks/TransitionMock/FirstView.xaml.cs | 5 +- .../Mocks/TransitionMock/SecondView.xaml.cs | 5 +- .../Mocks/TransitionMock/TCMockWindow.xaml.cs | 5 +- .../TransitionMock/TCMockWindowViewModel.cs | 4 +- .../FakeViewWithContract.cs | 4 +- .../Platforms/wpf/Mocks/WpfTestUserControl.cs | 4 +- .../Suspension/DummyAppState.cs | 4 +- .../ReactiveUIBuilderWinUIExtensions.cs | 4 + .../ReactiveUIBuilderWinFormsExtensions.cs | 4 + .../ReactiveUIBuilderWpfExtensions.cs | 4 + src/ReactiveUI.sln | 76 +++++++++++++ src/ReactiveUI/Builder/ReactiveUIBuilder.cs | 3 + .../Mixins/DependencyResolverMixins.cs | 6 + src/ReactiveUI/Properties/AssemblyInfo.cs | 1 + src/ReactiveUI/RxApp.cs | 17 ++- 54 files changed, 623 insertions(+), 188 deletions(-) create mode 100644 src/ReactiveUI.AndroidX/ReactiveUIBuilderAndroidXExtensions.cs create mode 100644 src/ReactiveUI.AndroidX/Registrations.cs create mode 100644 src/ReactiveUI.Builder.AndroidX.Tests/ReactiveUI.Builder.AndroidX.Tests.csproj create mode 100644 src/ReactiveUI.Builder.AndroidX.Tests/ReactiveUIBuilderAndroidXTests.cs create mode 100644 src/ReactiveUI.Builder.Maui.Tests/ReactiveUI.Builder.Maui.Tests.csproj create mode 100644 src/ReactiveUI.Builder.Maui.Tests/ReactiveUIBuilderMauiTests.cs create mode 100644 src/ReactiveUI.Builder.Tests/Platforms/Blazor/ReactiveUIBuilderBlazorTests.cs create mode 100644 src/ReactiveUI.Builder.Tests/Platforms/Drawing/ReactiveUIBuilderDrawingTests.cs create mode 100644 src/ReactiveUI.Builder.Tests/Platforms/WinForms/ReactiveUIBuilderWinFormsTests.cs create mode 100644 src/ReactiveUI.Builder.Tests/Platforms/Wpf/ReactiveUIBuilderWpfTests.cs create mode 100644 src/ReactiveUI.Builder.Tests/ReactiveUI.Builder.Tests.csproj create mode 100644 src/ReactiveUI.Builder.Tests/ReactiveUIBuilderBlockingTests.cs rename src/{ReactiveUI.Tests/ReactiveUIBuilderTests.cs => ReactiveUI.Builder.Tests/ReactiveUIBuilderCoreTests.cs} (62%) create mode 100644 src/ReactiveUI.Tests/Platforms/AndroidX/ReactiveUIBuilderAndroidXTests.cs diff --git a/src/ReactiveUI.AndroidX/ReactiveUIBuilderAndroidXExtensions.cs b/src/ReactiveUI.AndroidX/ReactiveUIBuilderAndroidXExtensions.cs new file mode 100644 index 000000000..4793d1938 --- /dev/null +++ b/src/ReactiveUI.AndroidX/ReactiveUIBuilderAndroidXExtensions.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.AndroidX; + +/// +/// AndroidX-specific extensions for ReactiveUIBuilder. +/// +public static class ReactiveUIBuilderAndroidXExtensions +{ + /// + /// Registers AndroidX-specific services. + /// + /// The builder instance. + /// The builder instance for method chaining. +#if NET6_0_OR_GREATER + [RequiresDynamicCode("WithAndroidX uses methods that require dynamic code generation")] + [RequiresUnreferencedCode("WithAndroidX uses methods that may require unreferenced code")] +#endif + public static Builder.ReactiveUIBuilder WithAndroidX(this Builder.ReactiveUIBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return builder.WithPlatformModule(); + } +} diff --git a/src/ReactiveUI.AndroidX/Registrations.cs b/src/ReactiveUI.AndroidX/Registrations.cs new file mode 100644 index 000000000..938762679 --- /dev/null +++ b/src/ReactiveUI.AndroidX/Registrations.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Android.OS; +using Android.Runtime; + +namespace ReactiveUI.AndroidX; + +/// +/// AndroidX platform registrations. +/// +/// +public class Registrations : IWantsToRegisterStuff +{ + /// +#if NET6_0_OR_GREATER + [RequiresDynamicCode("Register uses methods that require dynamic code generation")] + [RequiresUnreferencedCode("Register uses methods that may require unreferenced code")] +#endif + public void Register(Action, Type> registerFunction) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(registerFunction); +#else + if (registerFunction is null) + { + throw new ArgumentNullException(nameof(registerFunction)); + } +#endif + + // Leverage core Android platform registrations already present in ReactiveUI.Platforms android. + // This ensures IPlatformOperations, binding converters, and schedulers are configured. + new PlatformRegistrations().Register(registerFunction); + + // AndroidX specific registrations could be added here if needed in the future. + + // Ensure a SynchronizationContext exists on Android when not in unit tests. + if (!ModeDetector.InUnitTestRunner() && Looper.MyLooper() is null) + { + Looper.Prepare(); + } + } +} diff --git a/src/ReactiveUI.Blazor/ReactiveUIBuilderBlazorExtensions.cs b/src/ReactiveUI.Blazor/ReactiveUIBuilderBlazorExtensions.cs index 6483c15df..2ef3b38ce 100644 --- a/src/ReactiveUI.Blazor/ReactiveUIBuilderBlazorExtensions.cs +++ b/src/ReactiveUI.Blazor/ReactiveUIBuilderBlazorExtensions.cs @@ -15,6 +15,10 @@ public static class ReactiveUIBuilderBlazorExtensions /// /// The builder instance. /// The builder instance for method chaining. +#if NET6_0_OR_GREATER + [RequiresDynamicCode("WithBlazor uses methods that require dynamic code generation")] + [RequiresUnreferencedCode("WithBlazor uses methods that may require unreferenced code")] +#endif public static Builder.ReactiveUIBuilder WithBlazor(this Builder.ReactiveUIBuilder builder) { if (builder is null) diff --git a/src/ReactiveUI.Builder.AndroidX.Tests/ReactiveUI.Builder.AndroidX.Tests.csproj b/src/ReactiveUI.Builder.AndroidX.Tests/ReactiveUI.Builder.AndroidX.Tests.csproj new file mode 100644 index 000000000..94bf1bf65 --- /dev/null +++ b/src/ReactiveUI.Builder.AndroidX.Tests/ReactiveUI.Builder.AndroidX.Tests.csproj @@ -0,0 +1,15 @@ + + + net8.0-android + $(NoWarn);CS1591;SA1600 + false + + + + + + + + + + diff --git a/src/ReactiveUI.Builder.AndroidX.Tests/ReactiveUIBuilderAndroidXTests.cs b/src/ReactiveUI.Builder.AndroidX.Tests/ReactiveUIBuilderAndroidXTests.cs new file mode 100644 index 000000000..8973c4dcb --- /dev/null +++ b/src/ReactiveUI.Builder.AndroidX.Tests/ReactiveUIBuilderAndroidXTests.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.AndroidX; + +namespace ReactiveUI.Builder.AndroidX.Tests; + +public class ReactiveUIBuilderAndroidXTests +{ + [Fact] + public void WithAndroidX_Should_Register_Services() + { + using var locator = new ModernDependencyResolver(); + + locator.CreateBuilder() + .WithAndroidX() + .Build(); + + var commandBinder = locator.GetService(); + Assert.NotNull(commandBinder); + + var observableForProperty = locator.GetService(); + Assert.NotNull(observableForProperty); + } + + [Fact] + public void WithCoreServices_AndAndroidX_Should_Register_All_Services() + { + using var locator = new ModernDependencyResolver(); + + locator.CreateBuilder() + .WithCoreServices() + .WithAndroidX() + .Build(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + var commandBinder = locator.GetService(); + Assert.NotNull(commandBinder); + } +} diff --git a/src/ReactiveUI.Builder.Maui.Tests/ReactiveUI.Builder.Maui.Tests.csproj b/src/ReactiveUI.Builder.Maui.Tests/ReactiveUI.Builder.Maui.Tests.csproj new file mode 100644 index 000000000..e823efff5 --- /dev/null +++ b/src/ReactiveUI.Builder.Maui.Tests/ReactiveUI.Builder.Maui.Tests.csproj @@ -0,0 +1,16 @@ + + + net8.0;net9.0;net8.0-windows10.0.19041.0;net9.0-windows10.0.19041.0 + true + $(NoWarn);CS1591;SA1600 + false + + + + + + + + + + diff --git a/src/ReactiveUI.Builder.Maui.Tests/ReactiveUIBuilderMauiTests.cs b/src/ReactiveUI.Builder.Maui.Tests/ReactiveUIBuilderMauiTests.cs new file mode 100644 index 000000000..0756c628d --- /dev/null +++ b/src/ReactiveUI.Builder.Maui.Tests/ReactiveUIBuilderMauiTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Maui; + +namespace ReactiveUI.Builder.Maui.Tests; + +public class ReactiveUIBuilderMauiTests +{ + [Fact] + public void WithMaui_Should_Register_Services() + { + using var locator = new ModernDependencyResolver(); + + locator.CreateBuilder() + .WithMaui() + .Build(); + + var typeConverters = locator.GetServices(); + Assert.NotNull(typeConverters); + } + + [Fact] + public void WithCoreServices_AndMaui_Should_Register_All_Services() + { + using var locator = new ModernDependencyResolver(); + + locator.CreateBuilder() + .WithCoreServices() + .WithMaui() + .Build(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + var typeConverters = locator.GetServices(); + Assert.NotNull(typeConverters); + } +} diff --git a/src/ReactiveUI.Builder.Tests/Platforms/Blazor/ReactiveUIBuilderBlazorTests.cs b/src/ReactiveUI.Builder.Tests/Platforms/Blazor/ReactiveUIBuilderBlazorTests.cs new file mode 100644 index 000000000..182d58eb3 --- /dev/null +++ b/src/ReactiveUI.Builder.Tests/Platforms/Blazor/ReactiveUIBuilderBlazorTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Blazor; + +namespace ReactiveUI.Builder.Tests.Platforms.Blazor; + +public class ReactiveUIBuilderBlazorTests +{ + [Fact] + public void WithBlazor_Should_Register_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithBlazor().Build(); + + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + + var typeConverters = locator.GetServices(); + Assert.NotEmpty(typeConverters); + } + + [Fact] + public void WithCoreServices_AndBlazor_Should_Register_All_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithCoreServices().WithBlazor().Build(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + } +} diff --git a/src/ReactiveUI.Builder.Tests/Platforms/Drawing/ReactiveUIBuilderDrawingTests.cs b/src/ReactiveUI.Builder.Tests/Platforms/Drawing/ReactiveUIBuilderDrawingTests.cs new file mode 100644 index 000000000..867b76813 --- /dev/null +++ b/src/ReactiveUI.Builder.Tests/Platforms/Drawing/ReactiveUIBuilderDrawingTests.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Drawing; + +namespace ReactiveUI.Builder.Tests.Platforms.Drawing; + +public class ReactiveUIBuilderDrawingTests +{ + [Fact] + public void WithDrawing_Should_Register_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithDrawing().Build(); + + // Drawing registers bitmap loader in non-NETSTANDARD contexts; we can still assert no exception and core services with chaining + locator.CreateBuilder().WithCoreServices().WithDrawing().Build(); + var bindingConverters = locator.GetServices(); + Assert.NotNull(bindingConverters); + } +} diff --git a/src/ReactiveUI.Builder.Tests/Platforms/WinForms/ReactiveUIBuilderWinFormsTests.cs b/src/ReactiveUI.Builder.Tests/Platforms/WinForms/ReactiveUIBuilderWinFormsTests.cs new file mode 100644 index 000000000..8c90b8f44 --- /dev/null +++ b/src/ReactiveUI.Builder.Tests/Platforms/WinForms/ReactiveUIBuilderWinFormsTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Winforms; + +namespace ReactiveUI.Builder.Tests.Platforms.WinForms; + +public class ReactiveUIBuilderWinFormsTests +{ + [Fact] + public void WithWinForms_Should_Register_WinForms_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithWinForms().Build(); + + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + + var activationFetcher = locator.GetService(); + Assert.NotNull(activationFetcher); + } + + [Fact] + public void WithCoreServices_AndWinForms_Should_Register_All_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithCoreServices().WithWinForms().Build(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + } +} diff --git a/src/ReactiveUI.Builder.Tests/Platforms/Wpf/ReactiveUIBuilderWpfTests.cs b/src/ReactiveUI.Builder.Tests/Platforms/Wpf/ReactiveUIBuilderWpfTests.cs new file mode 100644 index 000000000..f058fbd73 --- /dev/null +++ b/src/ReactiveUI.Builder.Tests/Platforms/Wpf/ReactiveUIBuilderWpfTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Wpf; + +namespace ReactiveUI.Builder.Tests.Platforms.Wpf; + +public class ReactiveUIBuilderWpfTests +{ + [Fact] + public void WithWpf_Should_Register_Wpf_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithWpf().Build(); + + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + + var activationFetcher = locator.GetService(); + Assert.NotNull(activationFetcher); + } + + [Fact] + public void WithCoreServices_AndWpf_Should_Register_All_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithCoreServices().WithWpf().Build(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + } +} diff --git a/src/ReactiveUI.Builder.Tests/ReactiveUI.Builder.Tests.csproj b/src/ReactiveUI.Builder.Tests/ReactiveUI.Builder.Tests.csproj new file mode 100644 index 000000000..f17d6c292 --- /dev/null +++ b/src/ReactiveUI.Builder.Tests/ReactiveUI.Builder.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0;net9.0 + ;net8.0-windows10.0.17763.0;net9.0-windows10.0.17763.0 + $(NoWarn);CS1591 + enable + enable + false + $(NoWarn);SA1600 + + + true + true + + + + + + + + + + + + + + + + + diff --git a/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderBlockingTests.cs b/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderBlockingTests.cs new file mode 100644 index 000000000..1f43b1d8b --- /dev/null +++ b/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderBlockingTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.Tests; + +/// +/// Tests ensuring the builder blocks reflection-based initialization. +/// +public class ReactiveUIBuilderBlockingTests +{ + [Fact] + public void Build_SetsFlag_AndBlocks_InitializeReactiveUI() + { + using var locator = new ModernDependencyResolver(); + + RxApp.HasBeenBuiltUsingBuilder = false; + + var builder = locator.CreateBuilder(); + builder.WithCoreServices().Build(); + + Assert.True(GetHasBeenBuiltUsingBuilder()); + + locator.InitializeReactiveUI(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + } + + private static bool GetHasBeenBuiltUsingBuilder() + { + return typeof(RxApp).GetField("HasBeenBuiltUsingBuilder", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static) is { } field && (bool)field.GetValue(null)!; + } +} diff --git a/src/ReactiveUI.Tests/ReactiveUIBuilderTests.cs b/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderCoreTests.cs similarity index 62% rename from src/ReactiveUI.Tests/ReactiveUIBuilderTests.cs rename to src/ReactiveUI.Builder.Tests/ReactiveUIBuilderCoreTests.cs index be8d2be94..dad8b769c 100644 --- a/src/ReactiveUI.Tests/ReactiveUIBuilderTests.cs +++ b/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderCoreTests.cs @@ -3,46 +3,29 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using ReactiveUI.Builder; - -namespace ReactiveUI.Tests; +namespace ReactiveUI.Builder.Tests; /// /// Tests for the ReactiveUIBuilder core functionality. /// -public class ReactiveUIBuilderTests +public class ReactiveUIBuilderCoreTests { - /// - /// Test that the builder can be created from a dependency resolver. - /// [Fact] public void CreateBuilder_Should_Return_Builder_Instance() { - // Arrange using var locator = new ModernDependencyResolver(); - - // Act var builder = locator.CreateBuilder(); - - // Assert Assert.NotNull(builder); Assert.IsType(builder); } - /// - /// Test that core services are registered when using the builder. - /// [Fact] public void WithCoreServices_Should_Register_Core_Services() { - // Arrange using var locator = new ModernDependencyResolver(); var builder = locator.CreateBuilder(); - - // Act builder.WithCoreServices().Build(); - // Assert var observableProperty = locator.GetService(); Assert.NotNull(observableProperty); @@ -50,171 +33,108 @@ public void WithCoreServices_Should_Register_Core_Services() Assert.NotNull(typeConverter); } - /// - /// Test that platform services are registered when using the builder. - /// [Fact] public void WithPlatformServices_Should_Register_Platform_Services() { - // Arrange using var locator = new ModernDependencyResolver(); var builder = locator.CreateBuilder(); - - // Act builder.WithPlatformServices().Build(); - // Assert - // Platform services vary by platform, so we check what's available var services = locator.GetServices(); Assert.NotNull(services); Assert.True(services.Any()); } - /// - /// Test that custom registration actions work. - /// [Fact] public void WithCustomRegistration_Should_Execute_Custom_Action() { - // Arrange using var locator = new ModernDependencyResolver(); var builder = locator.CreateBuilder(); var customServiceRegistered = false; - // Act builder.WithCustomRegistration(r => { r.RegisterConstant("TestValue", typeof(string)); customServiceRegistered = true; }).Build(); - // Assert Assert.True(customServiceRegistered); var service = locator.GetService(); Assert.Equal("TestValue", service); } - /// - /// Test that builder ensures core services are always registered. - /// [Fact] public void Build_Should_Always_Register_Core_Services() { - // Arrange using var locator = new ModernDependencyResolver(); var builder = locator.CreateBuilder(); - // Act - Build without explicitly calling WithCoreServices builder.Build(); - // Assert var observableProperty = locator.GetService(); Assert.NotNull(observableProperty); } - /// - /// Test that null resolver throws exception. - /// - [Fact] - public void Constructor_With_Null_Resolver_Should_Throw() - { - // Act & Assert - Assert.Throws(() => new ReactiveUIBuilder(null!)); - } - - /// - /// Test that null custom registration throws exception. - /// [Fact] public void WithCustomRegistration_With_Null_Action_Should_Throw() { - // Arrange using var locator = new ModernDependencyResolver(); var builder = locator.CreateBuilder(); - - // Act & Assert Assert.Throws(() => builder.WithCustomRegistration(null!)); } - /// - /// Test that WithViewsFromAssembly works correctly. - /// [Fact] public void WithViewsFromAssembly_Should_Register_Views() { - // Arrange using var locator = new ModernDependencyResolver(); var builder = locator.CreateBuilder(); - var assembly = typeof(ReactiveUIBuilderTests).Assembly; + var assembly = typeof(ReactiveUIBuilderCoreTests).Assembly; - // Act builder.WithViewsFromAssembly(assembly).Build(); - - // Assert - Should not throw any exceptions Assert.NotNull(builder); } - /// - /// Test that null assembly throws exception. - /// [Fact] public void WithViewsFromAssembly_With_Null_Assembly_Should_Throw() { - // Arrange using var locator = new ModernDependencyResolver(); var builder = locator.CreateBuilder(); - - // Act & Assert Assert.Throws(() => builder.WithViewsFromAssembly(null!)); } - /// - /// Test that multiple calls to WithCoreServices don't register services twice. - /// [Fact] public void WithCoreServices_Called_Multiple_Times_Should_Not_Register_Twice() { - // Arrange using var locator = new ModernDependencyResolver(); var builder = locator.CreateBuilder(); - // Act builder.WithCoreServices().WithCoreServices().Build(); - // Assert - // Verify that services are registered but not duplicated var services = locator.GetServices(); Assert.NotNull(services); Assert.True(services.Any()); } - /// - /// Test builder with fluent chaining. - /// [Fact] public void Builder_Should_Support_Fluent_Chaining() { - // Arrange using var locator = new ModernDependencyResolver(); var customServiceRegistered = false; - // Act locator.CreateBuilder() - .WithCoreServices() - .WithPlatformServices() - .WithCustomRegistration(r => - { - r.RegisterConstant("Test", typeof(string)); - customServiceRegistered = true; - }) - .Build(); - - // Assert + .WithCoreServices() + .WithPlatformServices() + .WithCustomRegistration(r => + { + r.RegisterConstant("Test", typeof(string)); + customServiceRegistered = true; + }) + .Build(); + Assert.True(customServiceRegistered); var service = locator.GetService(); Assert.Equal("Test", service); - // Verify core services are registered var observableProperty = locator.GetService(); Assert.NotNull(observableProperty); } diff --git a/src/ReactiveUI.Drawing/ReactiveUIBuilderDrawingExtensions.cs b/src/ReactiveUI.Drawing/ReactiveUIBuilderDrawingExtensions.cs index 97363ed31..2f6da8578 100644 --- a/src/ReactiveUI.Drawing/ReactiveUIBuilderDrawingExtensions.cs +++ b/src/ReactiveUI.Drawing/ReactiveUIBuilderDrawingExtensions.cs @@ -3,6 +3,8 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics.CodeAnalysis; + namespace ReactiveUI.Drawing; /// @@ -15,6 +17,10 @@ public static class ReactiveUIBuilderDrawingExtensions /// /// The builder instance. /// The builder instance for method chaining. +#if NET6_0_OR_GREATER + [RequiresDynamicCode("WithDrawing uses methods that require dynamic code generation")] + [RequiresUnreferencedCode("WithDrawing uses methods that may require unreferenced code")] +#endif public static Builder.ReactiveUIBuilder WithDrawing(this Builder.ReactiveUIBuilder builder) { if (builder is null) diff --git a/src/ReactiveUI.Maui/ReactiveUIBuilderMauiExtensions.cs b/src/ReactiveUI.Maui/ReactiveUIBuilderMauiExtensions.cs index 9a3d828f7..b3edea999 100644 --- a/src/ReactiveUI.Maui/ReactiveUIBuilderMauiExtensions.cs +++ b/src/ReactiveUI.Maui/ReactiveUIBuilderMauiExtensions.cs @@ -15,6 +15,10 @@ public static class ReactiveUIBuilderMauiExtensions /// /// The builder instance. /// The builder instance for method chaining. +#if NET6_0_OR_GREATER + [RequiresDynamicCode("WithMaui uses methods that require dynamic code generation")] + [RequiresUnreferencedCode("WithMaui uses methods that may require unreferenced code")] +#endif public static Builder.ReactiveUIBuilder WithMaui(this Builder.ReactiveUIBuilder builder) { if (builder is null) diff --git a/src/ReactiveUI.Tests/Locator/Mocks/BarViewModel.cs b/src/ReactiveUI.Tests/Locator/Mocks/BarViewModel.cs index 7672f3df7..078f31fb4 100644 --- a/src/ReactiveUI.Tests/Locator/Mocks/BarViewModel.cs +++ b/src/ReactiveUI.Tests/Locator/Mocks/BarViewModel.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A mock view. /// -public class BarViewModel : ReactiveObject, IBarViewModel -{ -} +public class BarViewModel : ReactiveObject, IBarViewModel; diff --git a/src/ReactiveUI.Tests/Locator/Mocks/FooViewModel.cs b/src/ReactiveUI.Tests/Locator/Mocks/FooViewModel.cs index c3852ba7a..3d917a182 100644 --- a/src/ReactiveUI.Tests/Locator/Mocks/FooViewModel.cs +++ b/src/ReactiveUI.Tests/Locator/Mocks/FooViewModel.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A mock view model. /// -public class FooViewModel : ReactiveObject, IFooViewModel -{ -} +public class FooViewModel : ReactiveObject, IFooViewModel; diff --git a/src/ReactiveUI.Tests/Locator/Mocks/FooViewModelWithWeirdName.cs b/src/ReactiveUI.Tests/Locator/Mocks/FooViewModelWithWeirdName.cs index 99a4ee34e..0b7084cfb 100644 --- a/src/ReactiveUI.Tests/Locator/Mocks/FooViewModelWithWeirdName.cs +++ b/src/ReactiveUI.Tests/Locator/Mocks/FooViewModelWithWeirdName.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A mock view model. /// -public class FooViewModelWithWeirdName : ReactiveObject, IFooViewModel -{ -} +public class FooViewModelWithWeirdName : ReactiveObject, IFooViewModel; diff --git a/src/ReactiveUI.Tests/Locator/Mocks/IBarViewModel.cs b/src/ReactiveUI.Tests/Locator/Mocks/IBarViewModel.cs index 65b0eec62..49fcce06b 100644 --- a/src/ReactiveUI.Tests/Locator/Mocks/IBarViewModel.cs +++ b/src/ReactiveUI.Tests/Locator/Mocks/IBarViewModel.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A mock interface view model. /// -public interface IBarViewModel -{ -} +public interface IBarViewModel; diff --git a/src/ReactiveUI.Tests/Locator/Mocks/IFooView.cs b/src/ReactiveUI.Tests/Locator/Mocks/IFooView.cs index 50190e527..de29557b1 100644 --- a/src/ReactiveUI.Tests/Locator/Mocks/IFooView.cs +++ b/src/ReactiveUI.Tests/Locator/Mocks/IFooView.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A interface mock view. /// -public interface IFooView : IViewFor -{ -} +public interface IFooView : IViewFor; diff --git a/src/ReactiveUI.Tests/Locator/Mocks/IFooViewModel.cs b/src/ReactiveUI.Tests/Locator/Mocks/IFooViewModel.cs index 16d8650bb..1b4b83948 100644 --- a/src/ReactiveUI.Tests/Locator/Mocks/IFooViewModel.cs +++ b/src/ReactiveUI.Tests/Locator/Mocks/IFooViewModel.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A interface view model. /// -public interface IFooViewModel -{ -} +public interface IFooViewModel; diff --git a/src/ReactiveUI.Tests/Locator/Mocks/IRoutableFooViewModel.cs b/src/ReactiveUI.Tests/Locator/Mocks/IRoutableFooViewModel.cs index a0f4c5007..1a0b17c3e 100644 --- a/src/ReactiveUI.Tests/Locator/Mocks/IRoutableFooViewModel.cs +++ b/src/ReactiveUI.Tests/Locator/Mocks/IRoutableFooViewModel.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A interface routable view model. /// -public interface IRoutableFooViewModel : IRoutableViewModel -{ -} +public interface IRoutableFooViewModel : IRoutableViewModel; diff --git a/src/ReactiveUI.Tests/Locator/Mocks/IStrangeInterfaceNotFollowingConvention.cs b/src/ReactiveUI.Tests/Locator/Mocks/IStrangeInterfaceNotFollowingConvention.cs index 431c71e4b..4d3bbce7c 100644 --- a/src/ReactiveUI.Tests/Locator/Mocks/IStrangeInterfaceNotFollowingConvention.cs +++ b/src/ReactiveUI.Tests/Locator/Mocks/IStrangeInterfaceNotFollowingConvention.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A strange interface. /// -public interface IStrangeInterfaceNotFollowingConvention -{ -} +public interface IStrangeInterfaceNotFollowingConvention; diff --git a/src/ReactiveUI.Tests/Locator/Mocks/StrangeClassNotFollowingConvention.cs b/src/ReactiveUI.Tests/Locator/Mocks/StrangeClassNotFollowingConvention.cs index 7b026d307..9d16469c6 100644 --- a/src/ReactiveUI.Tests/Locator/Mocks/StrangeClassNotFollowingConvention.cs +++ b/src/ReactiveUI.Tests/Locator/Mocks/StrangeClassNotFollowingConvention.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A strange class. /// -public class StrangeClassNotFollowingConvention : IStrangeInterfaceNotFollowingConvention -{ -} +public class StrangeClassNotFollowingConvention : IStrangeInterfaceNotFollowingConvention; diff --git a/src/ReactiveUI.Tests/Mocks/AnotherViewModel.cs b/src/ReactiveUI.Tests/Mocks/AnotherViewModel.cs index b0457c913..10f77086f 100644 --- a/src/ReactiveUI.Tests/Mocks/AnotherViewModel.cs +++ b/src/ReactiveUI.Tests/Mocks/AnotherViewModel.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A mock view model. /// -public class AnotherViewModel : ReactiveObject -{ -} +public class AnotherViewModel : ReactiveObject; diff --git a/src/ReactiveUI.Tests/Mocks/ExampleViewModel.cs b/src/ReactiveUI.Tests/Mocks/ExampleViewModel.cs index 0110f67f9..d8025bfe0 100644 --- a/src/ReactiveUI.Tests/Mocks/ExampleViewModel.cs +++ b/src/ReactiveUI.Tests/Mocks/ExampleViewModel.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A mock view model. /// -public class ExampleViewModel : ReactiveObject -{ -} +public class ExampleViewModel : ReactiveObject; diff --git a/src/ReactiveUI.Tests/Mocks/ExampleWindowViewModel.cs b/src/ReactiveUI.Tests/Mocks/ExampleWindowViewModel.cs index 7396f57c5..11e5f36a8 100644 --- a/src/ReactiveUI.Tests/Mocks/ExampleWindowViewModel.cs +++ b/src/ReactiveUI.Tests/Mocks/ExampleWindowViewModel.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A mock view model. /// -public class ExampleWindowViewModel : ReactiveObject -{ -} +public class ExampleWindowViewModel : ReactiveObject; diff --git a/src/ReactiveUI.Tests/Mocks/NeverUsedViewModel.cs b/src/ReactiveUI.Tests/Mocks/NeverUsedViewModel.cs index f85c2fdea..486e0a056 100644 --- a/src/ReactiveUI.Tests/Mocks/NeverUsedViewModel.cs +++ b/src/ReactiveUI.Tests/Mocks/NeverUsedViewModel.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A view model that is never used. /// -public class NeverUsedViewModel : ReactiveObject -{ -} +public class NeverUsedViewModel : ReactiveObject; diff --git a/src/ReactiveUI.Tests/Mocks/SingleInstanceExampleViewModel.cs b/src/ReactiveUI.Tests/Mocks/SingleInstanceExampleViewModel.cs index 7c8a21a27..aa6f42360 100644 --- a/src/ReactiveUI.Tests/Mocks/SingleInstanceExampleViewModel.cs +++ b/src/ReactiveUI.Tests/Mocks/SingleInstanceExampleViewModel.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A single instance example view model. /// -public class SingleInstanceExampleViewModel : ReactiveObject -{ -} +public class SingleInstanceExampleViewModel : ReactiveObject; diff --git a/src/ReactiveUI.Tests/Mocks/ViewModelWithWeirdName.cs b/src/ReactiveUI.Tests/Mocks/ViewModelWithWeirdName.cs index 5af659500..a4c8d8d55 100644 --- a/src/ReactiveUI.Tests/Mocks/ViewModelWithWeirdName.cs +++ b/src/ReactiveUI.Tests/Mocks/ViewModelWithWeirdName.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests; /// /// A view model with a weird name. /// -public class ViewModelWithWeirdName : ReactiveObject -{ -} +public class ViewModelWithWeirdName : ReactiveObject; diff --git a/src/ReactiveUI.Tests/Platforms/AndroidX/ReactiveUIBuilderAndroidXTests.cs b/src/ReactiveUI.Tests/Platforms/AndroidX/ReactiveUIBuilderAndroidXTests.cs new file mode 100644 index 000000000..f7bd52d7c --- /dev/null +++ b/src/ReactiveUI.Tests/Platforms/AndroidX/ReactiveUIBuilderAndroidXTests.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.AndroidX; + +namespace ReactiveUI.Tests.Platforms.AndroidX; + +/// +/// Tests for AndroidX-specific ReactiveUIBuilder functionality. +/// These run on desktop test host and only verify DI registrations, not Android runtime behavior. +/// +public class ReactiveUIBuilderAndroidXTests +{ + /// + /// Test that AndroidX services can be registered using the builder. + /// + [Fact] + public void WithAndroidX_Should_Register_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithAndroidX().Build(); + + // Core/platform Android registrations ensure these services exist + var commandBinder = locator.GetService(); + Assert.NotNull(commandBinder); + + var observableForProperty = locator.GetService(); + Assert.NotNull(observableForProperty); + } + + /// + /// Test fluent chaining with AndroidX. + /// + [Fact] + public void WithCoreServices_AndAndroidX_Should_Register_All_Services() + { + using var locator = new ModernDependencyResolver(); + locator.CreateBuilder() + .WithCoreServices() + .WithAndroidX() + .Build(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + var commandBinder = locator.GetService(); + Assert.NotNull(commandBinder); + } +} diff --git a/src/ReactiveUI.Tests/Platforms/winforms/Mocks/AnotherView.cs b/src/ReactiveUI.Tests/Platforms/winforms/Mocks/AnotherView.cs index d3b38a2bb..4d0877c0f 100644 --- a/src/ReactiveUI.Tests/Platforms/winforms/Mocks/AnotherView.cs +++ b/src/ReactiveUI.Tests/Platforms/winforms/Mocks/AnotherView.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests.Winforms; /// /// Another view. /// -public class AnotherView : ReactiveUI.Winforms.ReactiveUserControl -{ -} +public class AnotherView : ReactiveUI.Winforms.ReactiveUserControl; diff --git a/src/ReactiveUI.Tests/Platforms/winforms/Mocks/ContractExampleView.cs b/src/ReactiveUI.Tests/Platforms/winforms/Mocks/ContractExampleView.cs index 5309befd7..a5cdbebe8 100644 --- a/src/ReactiveUI.Tests/Platforms/winforms/Mocks/ContractExampleView.cs +++ b/src/ReactiveUI.Tests/Platforms/winforms/Mocks/ContractExampleView.cs @@ -9,6 +9,4 @@ namespace ReactiveUI.Tests.Winforms; /// A view model that is contracted. /// [ViewContract("contract")] -public class ContractExampleView : ReactiveUI.Winforms.ReactiveUserControl -{ -} +public class ContractExampleView : ReactiveUI.Winforms.ReactiveUserControl; diff --git a/src/ReactiveUI.Tests/Platforms/winforms/Mocks/ExampleView.cs b/src/ReactiveUI.Tests/Platforms/winforms/Mocks/ExampleView.cs index 64d4659d6..9a5371edd 100644 --- a/src/ReactiveUI.Tests/Platforms/winforms/Mocks/ExampleView.cs +++ b/src/ReactiveUI.Tests/Platforms/winforms/Mocks/ExampleView.cs @@ -8,6 +8,4 @@ namespace ReactiveUI.Tests.Winforms; /// /// An example view. /// -public class ExampleView : ReactiveUI.Winforms.ReactiveUserControl -{ -} +public class ExampleView : ReactiveUI.Winforms.ReactiveUserControl; diff --git a/src/ReactiveUI.Tests/Platforms/winforms/Mocks/TestControl.cs b/src/ReactiveUI.Tests/Platforms/winforms/Mocks/TestControl.cs index 246305433..2758bb785 100644 --- a/src/ReactiveUI.Tests/Platforms/winforms/Mocks/TestControl.cs +++ b/src/ReactiveUI.Tests/Platforms/winforms/Mocks/TestControl.cs @@ -7,6 +7,4 @@ namespace ReactiveUI.Tests.Winforms; -public class TestControl : Control, IActivatableView -{ -} +public class TestControl : Control, IActivatableView; diff --git a/src/ReactiveUI.Tests/Platforms/winforms/Mocks/TestFormNotCanActivate.cs b/src/ReactiveUI.Tests/Platforms/winforms/Mocks/TestFormNotCanActivate.cs index c6efd873d..e1e69773a 100644 --- a/src/ReactiveUI.Tests/Platforms/winforms/Mocks/TestFormNotCanActivate.cs +++ b/src/ReactiveUI.Tests/Platforms/winforms/Mocks/TestFormNotCanActivate.cs @@ -7,6 +7,4 @@ namespace ReactiveUI.Tests.Winforms; -public class TestFormNotCanActivate : Form, IActivatableView -{ -} +public class TestFormNotCanActivate : Form, IActivatableView; diff --git a/src/ReactiveUI.Tests/Platforms/winforms/Mocks/ViewWithoutMatchingName.cs b/src/ReactiveUI.Tests/Platforms/winforms/Mocks/ViewWithoutMatchingName.cs index 9f2d1c020..7b689e206 100644 --- a/src/ReactiveUI.Tests/Platforms/winforms/Mocks/ViewWithoutMatchingName.cs +++ b/src/ReactiveUI.Tests/Platforms/winforms/Mocks/ViewWithoutMatchingName.cs @@ -5,6 +5,4 @@ namespace ReactiveUI.Tests.Winforms; -public class ViewWithoutMatchingName : ReactiveUI.Winforms.ReactiveUserControl -{ -} +public class ViewWithoutMatchingName : ReactiveUI.Winforms.ReactiveUserControl; diff --git a/src/ReactiveUI.Tests/Platforms/wpf/Mocks/ExampleWindowView.cs b/src/ReactiveUI.Tests/Platforms/wpf/Mocks/ExampleWindowView.cs index 578ee3c8b..a4e4fe6c0 100644 --- a/src/ReactiveUI.Tests/Platforms/wpf/Mocks/ExampleWindowView.cs +++ b/src/ReactiveUI.Tests/Platforms/wpf/Mocks/ExampleWindowView.cs @@ -5,6 +5,4 @@ namespace ReactiveUI.Tests.Wpf; -public class ExampleWindowView : ReactiveWindow -{ -} +public class ExampleWindowView : ReactiveWindow; diff --git a/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/FirstView.xaml.cs b/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/FirstView.xaml.cs index b7e9d156b..d35df7577 100644 --- a/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/FirstView.xaml.cs +++ b/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/FirstView.xaml.cs @@ -12,8 +12,5 @@ namespace ReactiveUI.Tests.Wpf; /// public partial class FirstView : UserControl { - public FirstView() - { - InitializeComponent(); - } + public FirstView() => InitializeComponent(); } diff --git a/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/SecondView.xaml.cs b/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/SecondView.xaml.cs index ac8bef246..7fb26411a 100644 --- a/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/SecondView.xaml.cs +++ b/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/SecondView.xaml.cs @@ -12,8 +12,5 @@ namespace ReactiveUI.Tests.Wpf; /// public partial class SecondView : UserControl { - public SecondView() - { - InitializeComponent(); - } + public SecondView() => InitializeComponent(); } diff --git a/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/TCMockWindow.xaml.cs b/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/TCMockWindow.xaml.cs index ed4f4adff..83a0ecbd3 100644 --- a/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/TCMockWindow.xaml.cs +++ b/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/TCMockWindow.xaml.cs @@ -10,8 +10,5 @@ namespace ReactiveUI.Tests.Wpf; /// public partial class TCMockWindow { - public TCMockWindow() - { - InitializeComponent(); - } + public TCMockWindow() => InitializeComponent(); } diff --git a/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/TCMockWindowViewModel.cs b/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/TCMockWindowViewModel.cs index 5799d0ad5..cac76d69f 100644 --- a/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/TCMockWindowViewModel.cs +++ b/src/ReactiveUI.Tests/Platforms/wpf/Mocks/TransitionMock/TCMockWindowViewModel.cs @@ -5,6 +5,4 @@ namespace ReactiveUI.Tests.Wpf; -public class TCMockWindowViewModel : ReactiveObject -{ -} +public class TCMockWindowViewModel : ReactiveObject; diff --git a/src/ReactiveUI.Tests/Platforms/wpf/Mocks/ViewModelViewHosts/FakeViewWithContract.cs b/src/ReactiveUI.Tests/Platforms/wpf/Mocks/ViewModelViewHosts/FakeViewWithContract.cs index e7dfbe1ae..8e2d7f1e3 100644 --- a/src/ReactiveUI.Tests/Platforms/wpf/Mocks/ViewModelViewHosts/FakeViewWithContract.cs +++ b/src/ReactiveUI.Tests/Platforms/wpf/Mocks/ViewModelViewHosts/FakeViewWithContract.cs @@ -13,9 +13,7 @@ public static class FakeViewWithContract internal const string ContractA = "ContractA"; internal const string ContractB = "ContractB"; - public class MyViewModel : ReactiveObject - { - } + public class MyViewModel : ReactiveObject; /// /// Used as the default view with no contracted. diff --git a/src/ReactiveUI.Tests/Platforms/wpf/Mocks/WpfTestUserControl.cs b/src/ReactiveUI.Tests/Platforms/wpf/Mocks/WpfTestUserControl.cs index 3d6fdb361..48885a049 100644 --- a/src/ReactiveUI.Tests/Platforms/wpf/Mocks/WpfTestUserControl.cs +++ b/src/ReactiveUI.Tests/Platforms/wpf/Mocks/WpfTestUserControl.cs @@ -7,6 +7,4 @@ namespace ReactiveUI.Tests.Wpf; -public class WpfTestUserControl : UserControl, IActivatableView -{ -} +public class WpfTestUserControl : UserControl, IActivatableView; diff --git a/src/ReactiveUI.Tests/Suspension/DummyAppState.cs b/src/ReactiveUI.Tests/Suspension/DummyAppState.cs index a5ce4ead5..77b364c1e 100644 --- a/src/ReactiveUI.Tests/Suspension/DummyAppState.cs +++ b/src/ReactiveUI.Tests/Suspension/DummyAppState.cs @@ -5,6 +5,4 @@ namespace ReactiveUI.Tests.Suspension; -public class DummyAppState -{ -} +public class DummyAppState; diff --git a/src/ReactiveUI.WinUI/ReactiveUIBuilderWinUIExtensions.cs b/src/ReactiveUI.WinUI/ReactiveUIBuilderWinUIExtensions.cs index 86f63e5ef..708a423f6 100644 --- a/src/ReactiveUI.WinUI/ReactiveUIBuilderWinUIExtensions.cs +++ b/src/ReactiveUI.WinUI/ReactiveUIBuilderWinUIExtensions.cs @@ -15,6 +15,10 @@ public static class ReactiveUIBuilderWinUIExtensions /// /// The builder instance. /// The builder instance for method chaining. +#if NET6_0_OR_GREATER + [RequiresDynamicCode("WithWinUI uses methods that require dynamic code generation")] + [RequiresUnreferencedCode("WithWinUI uses methods that may require unreferenced code")] +#endif public static Builder.ReactiveUIBuilder WithWinUI(this Builder.ReactiveUIBuilder builder) { if (builder is null) diff --git a/src/ReactiveUI.Winforms/ReactiveUIBuilderWinFormsExtensions.cs b/src/ReactiveUI.Winforms/ReactiveUIBuilderWinFormsExtensions.cs index c5e7e2599..e78ed403f 100644 --- a/src/ReactiveUI.Winforms/ReactiveUIBuilderWinFormsExtensions.cs +++ b/src/ReactiveUI.Winforms/ReactiveUIBuilderWinFormsExtensions.cs @@ -15,6 +15,10 @@ public static class ReactiveUIBuilderWinFormsExtensions /// /// The builder instance. /// The builder instance for method chaining. +#if NET6_0_OR_GREATER + [RequiresDynamicCode("WithWinForms uses methods that require dynamic code generation")] + [RequiresUnreferencedCode("WithWinForms uses methods that may require unreferenced code")] +#endif public static Builder.ReactiveUIBuilder WithWinForms(this Builder.ReactiveUIBuilder builder) { if (builder is null) diff --git a/src/ReactiveUI.Wpf/ReactiveUIBuilderWpfExtensions.cs b/src/ReactiveUI.Wpf/ReactiveUIBuilderWpfExtensions.cs index 56e35c6b6..2c99b0da0 100644 --- a/src/ReactiveUI.Wpf/ReactiveUIBuilderWpfExtensions.cs +++ b/src/ReactiveUI.Wpf/ReactiveUIBuilderWpfExtensions.cs @@ -15,6 +15,10 @@ public static class ReactiveUIBuilderWpfExtensions /// /// The builder instance. /// The builder instance for method chaining. +#if NET6_0_OR_GREATER + [RequiresDynamicCode("WithWpf uses methods that require dynamic code generation")] + [RequiresUnreferencedCode("WithWpf uses methods that may require unreferenced code")] +#endif public static Builder.ReactiveUIBuilder WithWpf(this Builder.ReactiveUIBuilder builder) { if (builder is null) diff --git a/src/ReactiveUI.sln b/src/ReactiveUI.sln index 091aea185..a508e20e5 100644 --- a/src/ReactiveUI.sln +++ b/src/ReactiveUI.sln @@ -51,6 +51,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactiveUI.AndroidX", "Reac EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReactiveUI.AOT.Tests", "ReactiveUI.AOTTests\ReactiveUI.AOT.Tests.csproj", "{D9CF5BB9-12FC-CF7E-B415-ED924A3E87AE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReactiveUI.Builder.Tests", "ReactiveUI.Builder.Tests\ReactiveUI.Builder.Tests.csproj", "{8034024A-2804-4920-921A-86ADCCBF0838}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReactiveUI.Builder.AndroidX.Tests", "ReactiveUI.Builder.AndroidX.Tests\ReactiveUI.Builder.AndroidX.Tests.csproj", "{AD8E5DF1-80A6-F80D-3DD0-18D467352C68}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReactiveUI.Builder.Maui.Tests", "ReactiveUI.Builder.Maui.Tests\ReactiveUI.Builder.Maui.Tests.csproj", "{2A4B719C-4958-AD92-4A22-6472AAF98A37}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReactiveUI.Builder.WpfApp", "ReactiveUI.Builder.WpfApp\ReactiveUI.Builder.WpfApp.csproj", "{B5B3101B-6638-418D-AE82-59984D9D6AC7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -297,6 +305,70 @@ Global {D9CF5BB9-12FC-CF7E-B415-ED924A3E87AE}.Release|x64.Build.0 = Release|Any CPU {D9CF5BB9-12FC-CF7E-B415-ED924A3E87AE}.Release|x86.ActiveCfg = Release|Any CPU {D9CF5BB9-12FC-CF7E-B415-ED924A3E87AE}.Release|x86.Build.0 = Release|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Debug|arm64.ActiveCfg = Debug|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Debug|arm64.Build.0 = Debug|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Debug|x64.ActiveCfg = Debug|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Debug|x64.Build.0 = Debug|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Debug|x86.ActiveCfg = Debug|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Debug|x86.Build.0 = Debug|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Release|Any CPU.Build.0 = Release|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Release|arm64.ActiveCfg = Release|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Release|arm64.Build.0 = Release|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Release|x64.ActiveCfg = Release|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Release|x64.Build.0 = Release|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Release|x86.ActiveCfg = Release|Any CPU + {8034024A-2804-4920-921A-86ADCCBF0838}.Release|x86.Build.0 = Release|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Debug|arm64.ActiveCfg = Debug|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Debug|arm64.Build.0 = Debug|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Debug|x64.ActiveCfg = Debug|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Debug|x64.Build.0 = Debug|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Debug|x86.ActiveCfg = Debug|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Debug|x86.Build.0 = Debug|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Release|Any CPU.Build.0 = Release|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Release|arm64.ActiveCfg = Release|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Release|arm64.Build.0 = Release|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Release|x64.ActiveCfg = Release|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Release|x64.Build.0 = Release|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Release|x86.ActiveCfg = Release|Any CPU + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68}.Release|x86.Build.0 = Release|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Debug|arm64.ActiveCfg = Debug|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Debug|arm64.Build.0 = Debug|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Debug|x64.ActiveCfg = Debug|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Debug|x64.Build.0 = Debug|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Debug|x86.Build.0 = Debug|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Release|Any CPU.Build.0 = Release|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Release|arm64.ActiveCfg = Release|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Release|arm64.Build.0 = Release|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Release|x64.ActiveCfg = Release|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Release|x64.Build.0 = Release|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Release|x86.ActiveCfg = Release|Any CPU + {2A4B719C-4958-AD92-4A22-6472AAF98A37}.Release|x86.Build.0 = Release|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Debug|arm64.ActiveCfg = Debug|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Debug|arm64.Build.0 = Debug|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Debug|x64.ActiveCfg = Debug|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Debug|x64.Build.0 = Debug|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Debug|x86.ActiveCfg = Debug|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Debug|x86.Build.0 = Debug|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Release|Any CPU.Build.0 = Release|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Release|arm64.ActiveCfg = Release|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Release|arm64.Build.0 = Release|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Release|x64.ActiveCfg = Release|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Release|x64.Build.0 = Release|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Release|x86.ActiveCfg = Release|Any CPU + {B5B3101B-6638-418D-AE82-59984D9D6AC7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -307,6 +379,10 @@ Global {7ED6D69F-138F-40BD-9F37-3E4050E4D19B} = {2AE709FA-BE58-4287-BFD3-E80BEB605125} {CD8B19A9-316E-4FBC-8F0C-87ADC6AAD684} = {2AE709FA-BE58-4287-BFD3-E80BEB605125} {D9CF5BB9-12FC-CF7E-B415-ED924A3E87AE} = {2AE709FA-BE58-4287-BFD3-E80BEB605125} + {8034024A-2804-4920-921A-86ADCCBF0838} = {2AE709FA-BE58-4287-BFD3-E80BEB605125} + {AD8E5DF1-80A6-F80D-3DD0-18D467352C68} = {2AE709FA-BE58-4287-BFD3-E80BEB605125} + {2A4B719C-4958-AD92-4A22-6472AAF98A37} = {2AE709FA-BE58-4287-BFD3-E80BEB605125} + {B5B3101B-6638-418D-AE82-59984D9D6AC7} = {2AE709FA-BE58-4287-BFD3-E80BEB605125} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {9326B58C-0AD3-4527-B3F4-86B54673C62E} diff --git a/src/ReactiveUI/Builder/ReactiveUIBuilder.cs b/src/ReactiveUI/Builder/ReactiveUIBuilder.cs index 046757c19..7ddc3e1b1 100644 --- a/src/ReactiveUI/Builder/ReactiveUIBuilder.cs +++ b/src/ReactiveUI/Builder/ReactiveUIBuilder.cs @@ -126,6 +126,9 @@ public ReactiveUIBuilder WithViewsFromAssembly(Assembly assembly) #endif public void Build() { + // Mark RxApp as initialized using the builder so reflection-based initialization is disabled. + RxApp.HasBeenBuiltUsingBuilder = true; + // Ensure core services are always registered if (!_coreRegistered) { diff --git a/src/ReactiveUI/Mixins/DependencyResolverMixins.cs b/src/ReactiveUI/Mixins/DependencyResolverMixins.cs index 8ba9d9905..db04bca6e 100644 --- a/src/ReactiveUI/Mixins/DependencyResolverMixins.cs +++ b/src/ReactiveUI/Mixins/DependencyResolverMixins.cs @@ -26,6 +26,12 @@ public static class DependencyResolverMixins #endif public static void InitializeReactiveUI(this IMutableDependencyResolver resolver, params RegistrationNamespace[] registrationNamespaces) { + if (RxApp.HasBeenBuiltUsingBuilder) + { + // If the builder has been used, we don't need to register the default services again. + return; + } + resolver.ArgumentNullExceptionThrowIfNull(nameof(resolver)); registrationNamespaces.ArgumentNullExceptionThrowIfNull(nameof(registrationNamespaces)); diff --git a/src/ReactiveUI/Properties/AssemblyInfo.cs b/src/ReactiveUI/Properties/AssemblyInfo.cs index 2296177c6..5b787e1f7 100644 --- a/src/ReactiveUI/Properties/AssemblyInfo.cs +++ b/src/ReactiveUI/Properties/AssemblyInfo.cs @@ -15,3 +15,4 @@ [assembly: InternalsVisibleTo("ReactiveUI.Drawing")] [assembly: InternalsVisibleTo("ReactiveUI.WinUI")] [assembly: InternalsVisibleTo("ReactiveUI.AndroidX")] +[assembly: InternalsVisibleTo("ReactiveUI.Builder.Tests")] diff --git a/src/ReactiveUI/RxApp.cs b/src/ReactiveUI/RxApp.cs index 9a24a7bb3..a5d2bb572 100644 --- a/src/ReactiveUI/RxApp.cs +++ b/src/ReactiveUI/RxApp.cs @@ -84,15 +84,18 @@ static RxApp() _taskpoolScheduler = TaskPoolScheduler.Default; #endif - Locator.RegisterResolverCallbackChanged(() => + if (!HasBeenBuiltUsingBuilder) { - if (Locator.CurrentMutable is null) + Locator.RegisterResolverCallbackChanged(() => { - return; - } + if (Locator.CurrentMutable is null) + { + return; + } - Locator.CurrentMutable.InitializeReactiveUI(PlatformRegistrationManager.NamespacesToRegister); - }); + Locator.CurrentMutable.InitializeReactiveUI(PlatformRegistrationManager.NamespacesToRegister); + }); + } DefaultExceptionHandler = Observer.Create(ex => { @@ -232,6 +235,8 @@ public static ISuspensionHost SuspensionHost } } + internal static bool HasBeenBuiltUsingBuilder { get; set; } + private static IScheduler UnitTestMainThreadScheduler { get => _unitTestMainThreadScheduler ??= CurrentThreadScheduler.Instance; From 46a3726b596830bc9d6534549a1111846deb6cc0 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Thu, 14 Aug 2025 02:58:43 +0100 Subject: [PATCH 03/15] Add Demo app to demonstrate Builder operation --- .../ReactiveUIBuilderBlockingTests.cs | 7 +- src/ReactiveUI.Builder.WpfApp/App.xaml | 7 + src/ReactiveUI.Builder.WpfApp/App.xaml.cs | 95 +++++++++ src/ReactiveUI.Builder.WpfApp/AssemblyInfo.cs | 8 + src/ReactiveUI.Builder.WpfApp/MainWindow.xaml | 12 ++ .../MainWindow.xaml.cs | 59 +++++ .../ReactiveUI.Builder.WpfApp.csproj | 17 ++ .../Services/AppInstance.cs | 11 + .../Services/ChatNetworkMessage.cs | 66 ++++++ .../Services/ChatNetworkService.cs | 139 ++++++++++++ .../Services/FileJsonSuspensionDriver.cs | 74 +++++++ .../Services/RoomEventKind.cs | 27 +++ .../Services/RoomEventMessage.cs | 50 +++++ .../Services/WpfAutoSuspendHelper.cs | 42 ++++ .../ViewModels/AppBootstrapper.cs | 30 +++ .../ViewModels/ChatMessage.cs | 27 +++ .../ViewModels/ChatRoom.cs | 34 +++ .../ViewModels/ChatRoomViewModel.cs | 93 ++++++++ .../ViewModels/ChatState.cs | 22 ++ .../ViewModels/ChatStateChanged.cs | 11 + .../ViewModels/LobbyViewModel.cs | 201 ++++++++++++++++++ .../Views/ChatRoomView.xaml | 36 ++++ .../Views/ChatRoomView.xaml.cs | 62 ++++++ .../Views/LobbyView.xaml | 28 +++ .../Views/LobbyView.xaml.cs | 88 ++++++++ ...valTests.ReactiveUI.DotNet8_0.verified.txt | 5 + ...valTests.ReactiveUI.DotNet9_0.verified.txt | 5 + ...provalTests.ReactiveUI.Net4_7.verified.txt | 1 + ...rovalTests.Winforms.DotNet8_0.verified.txt | 2 + ...rovalTests.Winforms.DotNet9_0.verified.txt | 2 + ...piApprovalTests.Wpf.DotNet8_0.verified.txt | 2 + ...piApprovalTests.Wpf.DotNet9_0.verified.txt | 2 + 32 files changed, 1259 insertions(+), 6 deletions(-) create mode 100644 src/ReactiveUI.Builder.WpfApp/App.xaml create mode 100644 src/ReactiveUI.Builder.WpfApp/App.xaml.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/AssemblyInfo.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/MainWindow.xaml create mode 100644 src/ReactiveUI.Builder.WpfApp/MainWindow.xaml.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/ReactiveUI.Builder.WpfApp.csproj create mode 100644 src/ReactiveUI.Builder.WpfApp/Services/AppInstance.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/Services/ChatNetworkMessage.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/Services/ChatNetworkService.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/Services/FileJsonSuspensionDriver.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/Services/RoomEventKind.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/Services/RoomEventMessage.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/Services/WpfAutoSuspendHelper.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/ViewModels/AppBootstrapper.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/ViewModels/ChatMessage.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/ViewModels/ChatRoom.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/ViewModels/ChatRoomViewModel.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/ViewModels/ChatState.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/ViewModels/ChatStateChanged.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/ViewModels/LobbyViewModel.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/Views/ChatRoomView.xaml create mode 100644 src/ReactiveUI.Builder.WpfApp/Views/ChatRoomView.xaml.cs create mode 100644 src/ReactiveUI.Builder.WpfApp/Views/LobbyView.xaml create mode 100644 src/ReactiveUI.Builder.WpfApp/Views/LobbyView.xaml.cs diff --git a/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderBlockingTests.cs b/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderBlockingTests.cs index 1f43b1d8b..7554f401a 100644 --- a/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderBlockingTests.cs +++ b/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderBlockingTests.cs @@ -20,16 +20,11 @@ public void Build_SetsFlag_AndBlocks_InitializeReactiveUI() var builder = locator.CreateBuilder(); builder.WithCoreServices().Build(); - Assert.True(GetHasBeenBuiltUsingBuilder()); + Assert.True(RxApp.HasBeenBuiltUsingBuilder); locator.InitializeReactiveUI(); var observableProperty = locator.GetService(); Assert.NotNull(observableProperty); } - - private static bool GetHasBeenBuiltUsingBuilder() - { - return typeof(RxApp).GetField("HasBeenBuiltUsingBuilder", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static) is { } field && (bool)field.GetValue(null)!; - } } diff --git a/src/ReactiveUI.Builder.WpfApp/App.xaml b/src/ReactiveUI.Builder.WpfApp/App.xaml new file mode 100644 index 000000000..79a2684d9 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/App.xaml @@ -0,0 +1,7 @@ + + + diff --git a/src/ReactiveUI.Builder.WpfApp/App.xaml.cs b/src/ReactiveUI.Builder.WpfApp/App.xaml.cs new file mode 100644 index 000000000..49e1d113e --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/App.xaml.cs @@ -0,0 +1,95 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Reactive.Linq; +using System.Windows; +using ReactiveUI.Wpf; +using Splat; + +namespace ReactiveUI.Builder.WpfApp; + +/// +/// Interaction logic for App.xaml. +/// +[SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", Justification = "Disposed on application exit in OnExit")] +public partial class App : Application +{ + private Services.WpfAutoSuspendHelper? _autoSuspend; + private Services.FileJsonSuspensionDriver? _driver; + private Services.ChatNetworkService? _networkService; + + /// + /// Raises the event. + /// + /// A that contains the event data. + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + // Initialize ReactiveUI via the Builder + var locator = Locator.CurrentMutable; + var builder = locator.CreateBuilder(); + builder + .WithCoreServices() + .WithWpf() + .WithViewsFromAssembly(typeof(App).Assembly) + .WithCustomRegistration(r => + { + // Register IScreen implementation as a factory so creation happens after state is loaded + r.Register(() => new ViewModels.AppBootstrapper()); + + // Register MessageBus as a singleton if not already + if (Locator.Current.GetService() is null) + { + r.RegisterConstant(MessageBus.Current); + } + }) + .Build(); + + // Setup Suspension + RxApp.SuspensionHost.CreateNewAppState = () => new ViewModels.ChatState(); + + var statePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ReactiveUI.Builder.WpfApp", + "state.json"); + Directory.CreateDirectory(Path.GetDirectoryName(statePath)!); + + _driver = new Services.FileJsonSuspensionDriver(statePath); + _autoSuspend = new Services.WpfAutoSuspendHelper(this, _driver); + _autoSuspend.OnStartup(); + + // Load state from disk (or create new) + var loaded = _driver.LoadState().Wait(); + RxApp.SuspensionHost.AppState = loaded; + + // Start network service + _networkService = new Services.ChatNetworkService(); + _networkService.Start(); + + // Create and show the shell + var mainWindow = new MainWindow(); + MainWindow = mainWindow; + mainWindow.Show(); + } + + /// + /// Raises the event. + /// + /// An that contains the event data. + protected override void OnExit(ExitEventArgs e) + { + _networkService?.Dispose(); + if (_driver is not null && RxApp.SuspensionHost.AppState is not null) + { + _driver.SaveState(RxApp.SuspensionHost.AppState).Wait(); + } + + _autoSuspend?.OnExit(); + base.OnExit(e); + } +} diff --git a/src/ReactiveUI.Builder.WpfApp/AssemblyInfo.cs b/src/ReactiveUI.Builder.WpfApp/AssemblyInfo.cs new file mode 100644 index 000000000..d6bbbfdbe --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/AssemblyInfo.cs @@ -0,0 +1,8 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Windows; + +[assembly: ThemeInfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)] diff --git a/src/ReactiveUI.Builder.WpfApp/MainWindow.xaml b/src/ReactiveUI.Builder.WpfApp/MainWindow.xaml new file mode 100644 index 000000000..ce51432bb --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/MainWindow.xaml @@ -0,0 +1,12 @@ + + + + + diff --git a/src/ReactiveUI.Builder.WpfApp/MainWindow.xaml.cs b/src/ReactiveUI.Builder.WpfApp/MainWindow.xaml.cs new file mode 100644 index 000000000..47e2b54eb --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/MainWindow.xaml.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Windows; +using Splat; + +namespace ReactiveUI.Builder.WpfApp; + +/// +/// Interaction logic for MainWindow.xaml. +/// +public partial class MainWindow : Window, IViewFor +{ + /// + /// The view model property. + /// + public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register( + nameof(ViewModel), typeof(ViewModels.AppBootstrapper), typeof(MainWindow), new PropertyMetadata(null)); + + /// + /// Initializes a new instance of the class. + /// + public MainWindow() + { + InitializeComponent(); + + // Set up content host with routing + var host = new RoutedViewHost + { + Router = Locator.Current.GetService()!.Router, + DefaultContent = new System.Windows.Controls.TextBlock { Text = "Loading...", HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }, + }; + + Content = host; + ViewModel = (ViewModels.AppBootstrapper)Locator.Current.GetService()!; + } + + /// + /// Gets or sets the ViewModel corresponding to this specific View. This should be + /// a DependencyProperty if you're using XAML. + /// + public ViewModels.AppBootstrapper? ViewModel + { + get => (ViewModels.AppBootstrapper?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + /// + /// Gets or sets the ViewModel corresponding to this specific View. This should be + /// a DependencyProperty if you're using XAML. + /// + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = (ViewModels.AppBootstrapper?)value; + } +} diff --git a/src/ReactiveUI.Builder.WpfApp/ReactiveUI.Builder.WpfApp.csproj b/src/ReactiveUI.Builder.WpfApp/ReactiveUI.Builder.WpfApp.csproj new file mode 100644 index 000000000..c755cf719 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ReactiveUI.Builder.WpfApp.csproj @@ -0,0 +1,17 @@ + + + + WinExe + net9.0-windows10.0.19041.0 + enable + enable + true + false + A sample WPF application using ReactiveUI. + + + + + + + diff --git a/src/ReactiveUI.Builder.WpfApp/Services/AppInstance.cs b/src/ReactiveUI.Builder.WpfApp/Services/AppInstance.cs new file mode 100644 index 000000000..0af0f7ad4 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Services/AppInstance.cs @@ -0,0 +1,11 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.Services; + +internal static class AppInstance +{ + public static readonly Guid Id = Guid.NewGuid(); +} diff --git a/src/ReactiveUI.Builder.WpfApp/Services/ChatNetworkMessage.cs b/src/ReactiveUI.Builder.WpfApp/Services/ChatNetworkMessage.cs new file mode 100644 index 000000000..6900759b6 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Services/ChatNetworkMessage.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.Services; + +/// +/// Network message payload used to broadcast chat messages. +/// +public sealed class ChatNetworkMessage +{ + /// + /// Initializes a new instance of the class. + /// + public ChatNetworkMessage() + { + } + + /// + /// Initializes a new instance of the class with values. + /// + /// The unique identifier for the room. + /// The human-readable room name used as the MessageBus contract. + /// The sender name. + /// The message text. + /// The message timestamp. + public ChatNetworkMessage(string roomId, string roomName, string sender, string text, DateTimeOffset timestamp) + { + RoomId = roomId; + RoomName = roomName; + Sender = sender; + Text = text; + Timestamp = timestamp; + } + + /// + /// Gets or sets the room ID. + /// + public string RoomId { get; set; } = string.Empty; + + /// + /// Gets or sets the room name. + /// + public string RoomName { get; set; } = string.Empty; + + /// + /// Gets or sets the sender. + /// + public string Sender { get; set; } = string.Empty; + + /// + /// Gets or sets the message text. + /// + public string Text { get; set; } = string.Empty; + + /// + /// Gets or sets the timestamp. + /// + public DateTimeOffset Timestamp { get; set; } + + /// + /// Gets or sets the originating app instance id. + /// + public Guid InstanceId { get; set; } +} diff --git a/src/ReactiveUI.Builder.WpfApp/Services/ChatNetworkService.cs b/src/ReactiveUI.Builder.WpfApp/Services/ChatNetworkService.cs new file mode 100644 index 000000000..eb4bd221d --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Services/ChatNetworkService.cs @@ -0,0 +1,139 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Net; +using System.Net.Sockets; +using System.Text.Json; + +namespace ReactiveUI.Builder.WpfApp.Services; + +/// +/// A simple UDP-based network relay to share chat messages and room events between app instances. +/// +public sealed class ChatNetworkService : IDisposable +{ + private const string RoomsContract = "__rooms__"; + private const int Port = 54545; + + // IPv4 local multicast address + private static readonly IPAddress MulticastAddress = IPAddress.Parse("239.255.0.1"); + + private readonly UdpClient _udp; // sender + private readonly IPEndPoint _sendEndpoint; + private readonly CancellationTokenSource _cts = new(); + + /// + /// Initializes a new instance of the class. + /// + public ChatNetworkService() + { + _sendEndpoint = new IPEndPoint(MulticastAddress, Port); + _udp = new UdpClient(AddressFamily.InterNetwork); + + try + { + // Enable multicast loopback so we can also receive our messages (we filter locally using InstanceId) + _udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 1); + _udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true); + } + catch + { + // ignore + } + + // Outgoing chat messages (default contract) + MessageBus.Current.Listen() + .Subscribe(Send); + + // Outgoing room events + MessageBus.Current.Listen(contract: RoomsContract) + .Subscribe(Send); + } + + /// + /// Starts the background receive loop. + /// + public void Start() => Task.Run(ReceiveLoop, _cts.Token); + + /// + public void Dispose() + { + _cts.Cancel(); + _udp.Dispose(); + _cts.Dispose(); + } + + private async Task ReceiveLoop() + { + using var listener = new UdpClient(AddressFamily.InterNetwork); + try + { + // Allow multiple processes to bind the same UDP port + listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + listener.ExclusiveAddressUse = false; + listener.Client.Bind(new IPEndPoint(IPAddress.Any, Port)); + + // Join multicast group on default interface + listener.JoinMulticastGroup(MulticastAddress); + } + catch + { + return; + } + + while (!_cts.IsCancellationRequested) + { + try + { + var result = await listener.ReceiveAsync(_cts.Token).ConfigureAwait(false); + var buffer = result.Buffer; + + // Inspect JSON for known properties to determine message type + using var doc = JsonDocument.Parse(buffer); + var root = doc.RootElement; + var isRoomEvent = root.TryGetProperty("Kind", out _) || root.TryGetProperty("Snapshot", out _); + + if (isRoomEvent) + { + var evt = JsonSerializer.Deserialize(buffer); + if (evt is not null) + { + MessageBus.Current.SendMessage(evt, contract: RoomsContract); + } + + continue; + } + + // Otherwise treat as chat message + var chat = JsonSerializer.Deserialize(buffer); + if (chat is not null) + { + MessageBus.Current.SendMessage(chat, contract: chat.RoomName); + } + } + catch (OperationCanceledException) + { + break; + } + catch + { + // ignore malformed input + } + } + } + + private void Send(object message) + { + try + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(message, message.GetType()); + _udp.Send(bytes, bytes.Length, _sendEndpoint); + } + catch + { + // ignore + } + } +} diff --git a/src/ReactiveUI.Builder.WpfApp/Services/FileJsonSuspensionDriver.cs b/src/ReactiveUI.Builder.WpfApp/Services/FileJsonSuspensionDriver.cs new file mode 100644 index 000000000..656fa6326 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Services/FileJsonSuspensionDriver.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.IO; +using System.Reactive; +using System.Reactive.Linq; +using System.Text.Json; + +namespace ReactiveUI.Builder.WpfApp.Services; + +/// +/// FileJsonSuspensionDriver. +/// +/// +/// +/// Initializes a new instance of the class. +/// +/// The path. +public sealed class FileJsonSuspensionDriver(string path) : ISuspensionDriver +{ + private readonly JsonSerializerOptions _options = new() { WriteIndented = true }; + + /// + /// Invalidates the application state (i.e. deletes it from disk). + /// + /// + /// A completed observable. + /// + public IObservable InvalidateState() => Observable.Start( + () => + { + if (File.Exists(path)) + { + File.Delete(path); + } + }, + RxApp.TaskpoolScheduler); + + /// + /// Loads the application state from persistent storage. + /// + /// + /// An object observable. + /// + public IObservable LoadState() => Observable.Start( + () => + { + if (!File.Exists(path)) + { + return new ViewModels.ChatState(); + } + + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json) ?? new ViewModels.ChatState(); + }, + RxApp.TaskpoolScheduler); + + /// + /// Saves the application state to disk. + /// + /// The application state. + /// + /// A completed observable. + /// + public IObservable SaveState(object state) => Observable.Start( + () => + { + var json = JsonSerializer.Serialize(state, _options); + File.WriteAllText(path, json); + }, + RxApp.TaskpoolScheduler); +} diff --git a/src/ReactiveUI.Builder.WpfApp/Services/RoomEventKind.cs b/src/ReactiveUI.Builder.WpfApp/Services/RoomEventKind.cs new file mode 100644 index 000000000..ecf799f90 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Services/RoomEventKind.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.Services; + +/// +/// The type of room event. +/// +public enum RoomEventKind +{ + /// + /// A new room was created. + /// + Add, + + /// + /// A room was removed. + /// + Remove, + + /// + /// Request others to broadcast their current rooms. + /// + SyncRequest, +} diff --git a/src/ReactiveUI.Builder.WpfApp/Services/RoomEventMessage.cs b/src/ReactiveUI.Builder.WpfApp/Services/RoomEventMessage.cs new file mode 100644 index 000000000..eb3236073 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Services/RoomEventMessage.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.Services; + +/// +/// Network event describing a change in the rooms list. +/// +public sealed class RoomEventMessage +{ + /// + /// Initializes a new instance of the class. + /// + public RoomEventMessage() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The event kind. + /// The room name. + public RoomEventMessage(RoomEventKind kind, string roomName) + { + Kind = kind; + RoomName = roomName; + } + + /// + /// Gets or sets the event kind. + /// + public RoomEventKind Kind { get; set; } + + /// + /// Gets or sets the room name for this event. + /// + public string RoomName { get; set; } = string.Empty; + + /// + /// Gets or sets the originating instance id. + /// + public Guid InstanceId { get; set; } + + /// + /// Gets or sets the current snapshot of room names. Used in response to SyncRequest. + /// + public List? Snapshot { get; set; } +} diff --git a/src/ReactiveUI.Builder.WpfApp/Services/WpfAutoSuspendHelper.cs b/src/ReactiveUI.Builder.WpfApp/Services/WpfAutoSuspendHelper.cs new file mode 100644 index 000000000..0fbcdc8c9 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Services/WpfAutoSuspendHelper.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive; +using System.Reactive.Linq; +using System.Windows; + +namespace ReactiveUI.Builder.WpfApp.Services; + +/// +/// WpfAutoSuspendHelper. +/// +/// +/// Initializes a new instance of the class. +/// +/// The application. +/// The driver. +public sealed class WpfAutoSuspendHelper(Application app, ISuspensionDriver driver) +{ + /// + /// Called on application exit to allow any cleanup. + /// + public void OnExit() + { + var d = driver; + } + + /// + /// Called on application startup to configure suspension. + /// + public void OnStartup() + { + RxApp.SuspensionHost.IsLaunchingNew = Observable.Return(Unit.Default); + RxApp.SuspensionHost.IsResuming = Observable.Never(); + RxApp.SuspensionHost.IsUnpausing = Observable.Never(); + RxApp.SuspensionHost.ShouldPersistState = Observable.Never(); + + RxApp.SuspensionHost.SetupDefaultSuspendResume(driver); + } +} diff --git a/src/ReactiveUI.Builder.WpfApp/ViewModels/AppBootstrapper.cs b/src/ReactiveUI.Builder.WpfApp/ViewModels/AppBootstrapper.cs new file mode 100644 index 000000000..c9c118a8b --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ViewModels/AppBootstrapper.cs @@ -0,0 +1,30 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.ViewModels; + +/// +/// AppBootstrapper. +/// +/// +/// +public class AppBootstrapper : ReactiveObject, IScreen +{ + /// + /// Initializes a new instance of the class. + /// + public AppBootstrapper() + { + Router = new RoutingState(); + + // Navigate to Lobby on start + Router.Navigate.Execute(new LobbyViewModel(this)).Subscribe(); + } + + /// + /// Gets the Router associated with this Screen. + /// + public RoutingState Router { get; } +} diff --git a/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatMessage.cs b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatMessage.cs new file mode 100644 index 000000000..7bf7ac27e --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatMessage.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.ViewModels; + +/// +/// A single chat message. +/// +public class ChatMessage +{ + /// + /// Gets or sets the sender name. + /// + public string Sender { get; set; } = string.Empty; + + /// + /// Gets or sets the message text. + /// + public string Text { get; set; } = string.Empty; + + /// + /// Gets or sets the timestamp. + /// + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatRoom.cs b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatRoom.cs new file mode 100644 index 000000000..2ca4a045a --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatRoom.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; + +namespace ReactiveUI.Builder.WpfApp.ViewModels; + +/// +/// Represents a chat room with messages and members. +/// +public class ChatRoom +{ + /// + /// Gets or sets the room id. + /// + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + + /// + /// Gets or sets the room name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the messages in the room. + /// + public ObservableCollection Messages { get; set; } = new(); + + /// + /// Gets or sets the members in the room. + /// + public List Members { get; set; } = new(); +} diff --git a/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatRoomViewModel.cs b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatRoomViewModel.cs new file mode 100644 index 000000000..06aaeacc6 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatRoomViewModel.cs @@ -0,0 +1,93 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive; +using System.Reactive.Linq; + +namespace ReactiveUI.Builder.WpfApp.ViewModels; + +/// +/// View model for a single chat room. +/// +public class ChatRoomViewModel : ReactiveObject, IRoutableViewModel +{ + private readonly IScreen _hostScreen; + private readonly ChatRoom _room; + private readonly string _user; + private string _messageText = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + /// The host screen. + /// The room. + /// The user. + public ChatRoomViewModel(IScreen hostScreen, ChatRoom room, string user) + { + ArgumentNullException.ThrowIfNull(room); + _hostScreen = hostScreen; + HostScreen = hostScreen; + UrlPathSegment = $"room/{room.Name}"; + _room = room; + _user = user; + + var canSend = this.WhenAnyValue(x => x.MessageText, txt => !string.IsNullOrWhiteSpace(txt)); + SendMessage = ReactiveCommand.Create(SendMessageImpl, canSend); + + // Observe new incoming messages via MessageBus using the room name as the contract across instances + MessageBus.Current.Listen(contract: room.Name) + .Where(msg => msg.InstanceId != Services.AppInstance.Id) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(msg => + { + _room.Messages.Add(new ChatMessage { Sender = msg.Sender, Text = msg.Text, Timestamp = msg.Timestamp }); + }); + } + + /// + public string UrlPathSegment { get; } + + /// + public IScreen HostScreen { get; } + + /// + /// Gets the room name. + /// + public string RoomName => _room.Name; + + /// + /// Gets the messages. + /// + public IReadOnlyList Messages => _room.Messages; + + /// + /// Gets or sets the message text. + /// + public string MessageText + { + get => _messageText; + set => this.RaiseAndSetIfChanged(ref _messageText, value); + } + + /// + /// Gets command to send a message. + /// + public ReactiveCommand SendMessage { get; } + + private void SendMessageImpl() + { + var msg = new ChatMessage { Sender = _user, Text = MessageText, Timestamp = DateTimeOffset.Now }; + _room.Messages.Add(msg); + var networkMessage = new Services.ChatNetworkMessage(_room.Id, _room.Name, msg.Sender, msg.Text, msg.Timestamp) + { + InstanceId = Services.AppInstance.Id + }; + + // Post on null contract so the network service can broadcast to other instances. + MessageBus.Current.SendMessage(networkMessage); + + MessageText = string.Empty; + } +} diff --git a/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatState.cs b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatState.cs new file mode 100644 index 000000000..5cabada33 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatState.cs @@ -0,0 +1,22 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.ViewModels; + +/// +/// The persisted chat application state. +/// +public class ChatState +{ + /// + /// Gets or sets the available rooms. + /// + public List Rooms { get; set; } = new(); + + /// + /// Gets or sets the local user's display name. + /// + public string? DisplayName { get; set; } +} diff --git a/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatStateChanged.cs b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatStateChanged.cs new file mode 100644 index 000000000..a109fc812 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatStateChanged.cs @@ -0,0 +1,11 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.ViewModels; + +/// +/// Notification that the chat state has changed and observers should refresh. +/// +public sealed class ChatStateChanged; diff --git a/src/ReactiveUI.Builder.WpfApp/ViewModels/LobbyViewModel.cs b/src/ReactiveUI.Builder.WpfApp/ViewModels/LobbyViewModel.cs new file mode 100644 index 000000000..4c51cba1d --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ViewModels/LobbyViewModel.cs @@ -0,0 +1,201 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; + +namespace ReactiveUI.Builder.WpfApp.ViewModels; + +/// +/// The lobby view model which lists rooms and allows creating/joining rooms. +/// +public class LobbyViewModel : ReactiveObject, IRoutableViewModel +{ + private readonly ObservableAsPropertyHelper> _rooms; + private readonly IScreen _hostScreen; + private string _roomName = string.Empty; + private string _displayName = Environment.MachineName; + + /// + /// Initializes a new instance of the class. + /// + /// The host screen. + public LobbyViewModel(IScreen hostScreen) + { + _hostScreen = hostScreen; + HostScreen = hostScreen; + UrlPathSegment = "lobby"; + + var canCreate = this.WhenAnyValue(x => x.RoomName, rn => !string.IsNullOrWhiteSpace(rn)); + CreateRoom = ReactiveCommand.Create(CreateRoomImpl, canCreate); + + DeleteRoom = ReactiveCommand.Create(DeleteRoomImpl); + + JoinRoom = ReactiveCommand.CreateFromTask(async room => + { + ArgumentNullException.ThrowIfNull(room); + await HostScreen.Router.Navigate.Execute(new ChatRoomViewModel(HostScreen, room, DisplayName)); + }); + + // Local changes + var localRoomsChanged = MessageBus.Current.Listen().Select(_ => Unit.Default); + + // Remote changes and sync + var remoteRoomsChanged = MessageBus.Current + .Listen(contract: "__rooms__") + .Where(m => m.InstanceId != Services.AppInstance.Id) + .Do(evt => + { + switch (evt.Kind) + { + case Services.RoomEventKind.SyncRequest: + // Respond with our snapshot of room names + var snapshot = GetState().Rooms.ConvertAll(r => r.Name); + var response = new Services.RoomEventMessage(Services.RoomEventKind.Add, string.Empty) + { + Snapshot = snapshot, + InstanceId = Services.AppInstance.Id, + }; + MessageBus.Current.SendMessage(response, contract: "__rooms__"); + break; + default: + ApplyRoomEvent(evt); + break; + } + }) + .Select(_ => Unit.Default); + + RoomsChanged = localRoomsChanged.Merge(remoteRoomsChanged); + + this.WhenAnyObservable(x => x.RoomsChanged) + .StartWith(Unit.Default) + .Select(_ => (IReadOnlyList)[.. GetState().Rooms]) + .ObserveOn(RxApp.MainThreadScheduler) + .ToProperty(this, nameof(Rooms), out _rooms); + + // Request a snapshot from peers shortly after activation + RxApp.MainThreadScheduler.Schedule(Unit.Default, TimeSpan.FromMilliseconds(500), (s, __) => + { + var req = new Services.RoomEventMessage(Services.RoomEventKind.SyncRequest, string.Empty) { InstanceId = Services.AppInstance.Id }; + MessageBus.Current.SendMessage(req, contract: "__rooms__"); + return Disposable.Empty; + }); + } + + /// + public string UrlPathSegment { get; } + + /// + public IScreen HostScreen { get; } + + /// + /// Gets or sets the display name for the current user. + /// + public string DisplayName + { + get => _displayName; + set => this.RaiseAndSetIfChanged(ref _displayName, value); + } + + /// + /// Gets or sets the new room name. + /// + public string RoomName + { + get => _roomName; + set => this.RaiseAndSetIfChanged(ref _roomName, value); + } + + /// + /// Gets the current list of rooms. + /// + public IReadOnlyList Rooms => _rooms.Value; + + /// + /// Gets an observable signaling when the rooms change. + /// + public IObservable RoomsChanged { get; } + + /// + /// Gets the command which creates a new room. + /// + public ReactiveCommand CreateRoom { get; } + + /// + /// Gets the command which deletes a room. + /// + public ReactiveCommand DeleteRoom { get; } + + /// + /// Gets the command which joins an existing room. + /// + public ReactiveCommand JoinRoom { get; } + + private static ChatState GetState() => RxApp.SuspensionHost.GetAppState(); + + private static void ApplyRoomEvent(Services.RoomEventMessage evt) + { + var state = GetState(); + + if (evt.Snapshot is not null) + { + // Apply snapshot + foreach (var name in evt.Snapshot) + { + if (!state.Rooms.Any(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase))) + { + state.Rooms.Add(new ChatRoom { Name = name }); + } + } + + return; + } + + switch (evt.Kind) + { + case Services.RoomEventKind.Add: + if (!state.Rooms.Any(r => string.Equals(r.Name, evt.RoomName, StringComparison.OrdinalIgnoreCase))) + { + state.Rooms.Add(new ChatRoom { Name = evt.RoomName }); + } + + break; + case Services.RoomEventKind.Remove: + state.Rooms.RemoveAll(r => string.Equals(r.Name, evt.RoomName, StringComparison.OrdinalIgnoreCase)); + break; + } + } + + private void CreateRoomImpl() + { + var name = RoomName.Trim(); + var state = GetState(); + var existing = state.Rooms.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase)); + if (existing is null) + { + var room = new ChatRoom { Name = name }; + state.Rooms.Add(room); + + // Broadcast room add to peers + var evt = new Services.RoomEventMessage(Services.RoomEventKind.Add, room.Name) { InstanceId = Services.AppInstance.Id }; + MessageBus.Current.SendMessage(evt, contract: "__rooms__"); + } + + MessageBus.Current.SendMessage(new ChatStateChanged()); + RoomName = string.Empty; + } + + private void DeleteRoomImpl(ChatRoom room) + { + var state = GetState(); + if (state.Rooms.Remove(room)) + { + var evt = new Services.RoomEventMessage(Services.RoomEventKind.Remove, room.Name) { InstanceId = Services.AppInstance.Id }; + MessageBus.Current.SendMessage(evt, contract: "__rooms__"); + MessageBus.Current.SendMessage(new ChatStateChanged()); + } + } +} diff --git a/src/ReactiveUI.Builder.WpfApp/Views/ChatRoomView.xaml b/src/ReactiveUI.Builder.WpfApp/Views/ChatRoomView.xaml new file mode 100644 index 000000000..8d4eb4f76 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Views/ChatRoomView.xaml @@ -0,0 +1,36 @@ + + + + + + + + +