diff --git a/Directory.Packages.props b/Directory.Packages.props index dc48970f6..32d3ba4b4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,9 +1,10 @@ - - - - + + + + + @@ -13,7 +14,7 @@ - + @@ -25,7 +26,7 @@ - + diff --git a/src/messaging/dotnet/src/Client/Client/MessageRouterClient.cs b/src/messaging/dotnet/src/Client/Client/MessageRouterClient.cs index 117b7cbe1..6d0a683bf 100644 --- a/src/messaging/dotnet/src/Client/Client/MessageRouterClient.cs +++ b/src/messaging/dotnet/src/Client/Client/MessageRouterClient.cs @@ -11,6 +11,7 @@ // and limitations under the License. using System.Collections.Concurrent; +using System.Diagnostics; using System.Threading.Channels; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -436,6 +437,8 @@ private async Task SendRequestAsync( CancellationToken cancellationToken) where TResponse : AbstractResponse { + CheckNotOnMainThread(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (!_pendingRequests.TryAdd(request.RequestId, tcs)) @@ -727,6 +730,17 @@ private ValueTask TryUnsubscribe(Topic topic) : default; } + [DebuggerStepThrough] + private static void CheckNotOnMainThread() + { +#if DEBUG + if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA) + { + throw new InvalidOperationException("The current thread is the main thread. Awaiting the resulting Task can cause a deadlock."); + } +#endif + } + private enum ConnectionState { NotConnected, diff --git a/src/messaging/dotnet/src/Server/Server/MessageRouterServer.cs b/src/messaging/dotnet/src/Server/Server/MessageRouterServer.cs index 5e5cf350c..3b97e1fab 100644 --- a/src/messaging/dotnet/src/Server/Server/MessageRouterServer.cs +++ b/src/messaging/dotnet/src/Server/Server/MessageRouterServer.cs @@ -21,7 +21,6 @@ namespace MorganStanley.ComposeUI.Messaging.Server; -// TODO: Also implement IMessageRouter to speed up in-process messaging internal class MessageRouterServer : IMessageRouterServer { public MessageRouterServer( diff --git a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/IModuleRunner.cs b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/IModuleRunner.cs index 45851b68d..b44e28201 100644 --- a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/IModuleRunner.cs +++ b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/IModuleRunner.cs @@ -16,7 +16,7 @@ public interface IModuleRunner { string ModuleType { get; } - Task Start(IModuleInstance moduleInstance, StartupContext startupContext, Func pipeline); + Task Start(StartupContext startupContext, Func pipeline); Task Stop(IModuleInstance moduleInstance); } diff --git a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/ModuleLoaderConstants.cs b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/ModuleLoaderConstants.cs new file mode 100644 index 000000000..09ebc14b0 --- /dev/null +++ b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/ModuleLoaderConstants.cs @@ -0,0 +1,18 @@ +// Morgan Stanley makes this available to you under the Apache License, +// Version 2.0 (the "License"). You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0. +// +// See the NOTICE file distributed with this work for additional information +// regarding copyright ownership. Unless required by applicable law or agreed +// to in writing, software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions +// and limitations under the License. + +namespace MorganStanley.ComposeUI.ModuleLoader; + +public static class ModuleLoaderConstants +{ + public static readonly Uri DefaultUri = new("about:blank"); +} \ No newline at end of file diff --git a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/StartupContext.cs b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/StartupContext.cs index 04ada691e..823ce6397 100644 --- a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/StartupContext.cs +++ b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/StartupContext.cs @@ -17,13 +17,16 @@ public sealed class StartupContext private readonly object _lock = new(); private readonly List _properties = new(); - public StartupContext(StartRequest startRequest) + public StartupContext(StartRequest startRequest, IModuleInstance moduleInstance) { StartRequest = startRequest; + ModuleInstance = moduleInstance; } public StartRequest StartRequest { get; } + public IModuleInstance ModuleInstance { get; } + public void AddProperty(T value) { ArgumentNullException.ThrowIfNull(value, nameof(value)); @@ -42,3 +45,34 @@ public IEnumerable GetProperties() } } } + +public static class StartupContextExtensions +{ + public static IEnumerable GetProperties(this StartupContext startupContext) + { + return startupContext.GetProperties().OfType(); + } + + public static T GetOrAddProperty(this StartupContext startupContext, Func newValueFactory) + { + var property = startupContext.GetProperties().FirstOrDefault(); + + if (property == null) + { + property = newValueFactory(startupContext); + startupContext.AddProperty(property); + } + + return property; + } + + public static T GetOrAddProperty(this StartupContext startupContext, Func newValueFactory) + { + return GetOrAddProperty(startupContext, _ => newValueFactory()); + } + + public static T GetOrAddProperty(this StartupContext startupContext) where T : class, new() + { + return GetOrAddProperty(startupContext, _ => new T()); + } +} diff --git a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/WebManifestDetails.cs b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/WebManifestDetails.cs index dfabef376..f2fac293d 100644 --- a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/WebManifestDetails.cs +++ b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/WebManifestDetails.cs @@ -23,7 +23,7 @@ public sealed class WebManifestDetails /// /// The URL to open when this module is started. /// - public Uri Url { get; init; } + public Uri Url { get; init; } = ModuleLoaderConstants.DefaultUri; /// /// The URL of the window icon, if any. diff --git a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/WebStartupProperties.cs b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/WebStartupProperties.cs index 3b724d6c7..6150bf66d 100644 --- a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/WebStartupProperties.cs +++ b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader.Abstractions/WebStartupProperties.cs @@ -18,6 +18,9 @@ namespace MorganStanley.ComposeUI.ModuleLoader; /// public sealed class WebStartupProperties { - public Uri Url { get; set; } + public Uri Url { get; set; } = ModuleLoaderConstants.DefaultUri; public Uri? IconUrl { get; set; } -} \ No newline at end of file + public List ScriptProviders { get; } = new(); +} + +public delegate ValueTask WebModuleScriptProvider(IModuleInstance moduleInstance); diff --git a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/DependencyInjection/ServiceCollectionExtensions.cs b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/DependencyInjection/ServiceCollectionExtensions.cs index 1fc138a3a..6f6848d27 100644 --- a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/DependencyInjection/ServiceCollectionExtensions.cs @@ -13,6 +13,7 @@ using MorganStanley.ComposeUI.ModuleLoader; using MorganStanley.ComposeUI.ModuleLoader.Runners; +// ReSharper disable once CheckNamespace namespace Microsoft.Extensions.DependencyInjection; public static class ServiceCollectionExtensions diff --git a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/ModuleLoader.cs b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/ModuleLoader.cs index 3225d58d0..c08539ec1 100644 --- a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/ModuleLoader.cs +++ b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/ModuleLoader.cs @@ -47,7 +47,7 @@ public async Task StartModule(StartRequest request) throw new Exception($"Unknown Module id: {request.ModuleId}"); } - if (!_moduleRunners.TryGetValue(request.ModuleId, out var moduleRunner)) + if (!_moduleRunners.TryGetValue(manifest.ModuleType, out var moduleRunner)) { throw new Exception($"No module runner available for {manifest.ModuleType} module type"); } @@ -57,7 +57,7 @@ public async Task StartModule(StartRequest request) _modules.TryAdd(instanceId, moduleInstance); _lifetimeEvents.OnNext(new LifetimeEvent.Starting(moduleInstance)); - var startupContext = new StartupContext(request); + var startupContext = new StartupContext(request, moduleInstance); var pipeline = _startupActions .Reverse() @@ -65,7 +65,7 @@ public async Task StartModule(StartRequest request) () => Task.CompletedTask, (next, action) => () => action.InvokeAsync(startupContext, next)); - await moduleRunner.Start(moduleInstance, startupContext, pipeline); + await moduleRunner.Start(startupContext, pipeline); moduleInstance.AddProperties(startupContext.GetProperties()); _lifetimeEvents.OnNext(new LifetimeEvent.Started(moduleInstance)); @@ -84,11 +84,12 @@ public async Task StopModule(StopRequest request) public async ValueTask DisposeAsync() { - _lifetimeEvents.Dispose(); foreach (var module in _modules.Values) { await StopModuleInternal(module); } + _modules.Clear(); + _lifetimeEvents.Dispose(); } private async Task StopModuleInternal(IModuleInstance moduleInstance) diff --git a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/Runners/NativeModuleRunner.cs b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/Runners/NativeModuleRunner.cs index 2374e3b60..0baca2fa4 100644 --- a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/Runners/NativeModuleRunner.cs +++ b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/Runners/NativeModuleRunner.cs @@ -16,7 +16,7 @@ internal class NativeModuleRunner : IModuleRunner { public string ModuleType => ComposeUI.ModuleLoader.ModuleType.Native; - public Task Start(IModuleInstance moduleInstance, StartupContext startupContext, Func pipeline) + public Task Start(StartupContext startupContext, Func pipeline) { throw new NotImplementedException(); } diff --git a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/Runners/WebModuleRunner.cs b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/Runners/WebModuleRunner.cs index dcc372c74..933d2b2f5 100644 --- a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/Runners/WebModuleRunner.cs +++ b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/Runners/WebModuleRunner.cs @@ -16,9 +16,9 @@ internal class WebModuleRunner : IModuleRunner { public string ModuleType => ComposeUI.ModuleLoader.ModuleType.Web; - public async Task Start(IModuleInstance moduleInstance, StartupContext startupContext, Func pipeline) + public async Task Start(StartupContext startupContext, Func pipeline) { - if (moduleInstance.Manifest.TryGetDetails(out WebManifestDetails details)) + if (startupContext.ModuleInstance.Manifest.TryGetDetails(out WebManifestDetails details)) { startupContext.AddProperty(new WebStartupProperties { IconUrl = details.IconUrl, Url = details.Url }); } diff --git a/src/module-loader/dotnet/tests/MorganStanley.ComposeUI.ModuleLoader.Abstractions.Tests/MorganStanley.ComposeUI.ModuleLoader.Abstractions.Tests.csproj b/src/module-loader/dotnet/tests/MorganStanley.ComposeUI.ModuleLoader.Abstractions.Tests/MorganStanley.ComposeUI.ModuleLoader.Abstractions.Tests.csproj index 325b1dd8d..e134c5bb3 100644 --- a/src/module-loader/dotnet/tests/MorganStanley.ComposeUI.ModuleLoader.Abstractions.Tests/MorganStanley.ComposeUI.ModuleLoader.Abstractions.Tests.csproj +++ b/src/module-loader/dotnet/tests/MorganStanley.ComposeUI.ModuleLoader.Abstractions.Tests/MorganStanley.ComposeUI.ModuleLoader.Abstractions.Tests.csproj @@ -11,6 +11,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/module-loader/dotnet/tests/MorganStanley.ComposeUI.ModuleLoader.Abstractions.Tests/StartupContextTests.cs b/src/module-loader/dotnet/tests/MorganStanley.ComposeUI.ModuleLoader.Abstractions.Tests/StartupContextTests.cs index 167245414..f66010475 100644 --- a/src/module-loader/dotnet/tests/MorganStanley.ComposeUI.ModuleLoader.Abstractions.Tests/StartupContextTests.cs +++ b/src/module-loader/dotnet/tests/MorganStanley.ComposeUI.ModuleLoader.Abstractions.Tests/StartupContextTests.cs @@ -10,6 +10,8 @@ // or implied. See the License for the specific language governing permissions // and limitations under the License. +using Moq; + namespace MorganStanley.ComposeUI.ModuleLoader.Abstractions.Tests; public class StartupContextTests @@ -23,7 +25,7 @@ public void WhenAdd_AddedValuesCanBeRetrieved() new MyContextInfo { Name = "Test2" } }; - StartupContext context = new StartupContext(new StartRequest("test")); + StartupContext context = new StartupContext(new StartRequest("test"), Mock.Of()); context.AddProperty(expected[0]); context.AddProperty(expected[1]); @@ -35,7 +37,7 @@ public void WhenAdd_AddedValuesCanBeRetrieved() [Fact] public void GivenNullArgument_WhenAdd_ThrowsArgumentNullException() { - StartupContext context = new StartupContext(new StartRequest("test")); + StartupContext context = new StartupContext(new StartRequest("test"), Mock.Of()); Assert.Throws(() => context.AddProperty(null!)); } diff --git a/src/module-loader/dotnet/tests/MorganStanley.ComposeUI.ModuleLoader.Tests/Runners/WebModuleRunnerTests.cs b/src/module-loader/dotnet/tests/MorganStanley.ComposeUI.ModuleLoader.Tests/Runners/WebModuleRunnerTests.cs index a9a9ab66d..3935fedb8 100644 --- a/src/module-loader/dotnet/tests/MorganStanley.ComposeUI.ModuleLoader.Tests/Runners/WebModuleRunnerTests.cs +++ b/src/module-loader/dotnet/tests/MorganStanley.ComposeUI.ModuleLoader.Tests/Runners/WebModuleRunnerTests.cs @@ -33,11 +33,11 @@ public async Task WhenStart_WebStartupPropertiesAreAddedToStartupContext() var moduleManifestMock = new MockModuleManifest(details); moduleInstanceMock.Setup(m => m.Manifest).Returns(moduleManifestMock); var startRequest = new StartRequest("test"); - var startupContext = new StartupContext(startRequest); + var startupContext = new StartupContext(startRequest, moduleInstanceMock.Object); static Task MockPipeline() => Task.CompletedTask; var runner = new WebModuleRunner(); - await runner.Start(moduleInstanceMock.Object, startupContext, MockPipeline); + await runner.Start(startupContext, MockPipeline); var result = startupContext.GetProperties(); Assert.NotNull(result); diff --git a/src/shell/dotnet/Shell.sln b/src/shell/dotnet/Shell.sln index b5735830b..89bb7e34b 100644 --- a/src/shell/dotnet/Shell.sln +++ b/src/shell/dotnet/Shell.sln @@ -23,6 +23,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MorganStanley.ComposeUI.Mes EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppDirectory", "..\..\fdc3\dotnet\AppDirectory\src\AppDirectory\AppDirectory.csproj", "{70EAC402-B711-4528-B4DE-34081EB8676E}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MorganStanley.ComposeUI.ModuleLoader.Abstractions", "..\..\module-loader\dotnet\src\MorganStanley.ComposeUI.ModuleLoader.Abstractions\MorganStanley.ComposeUI.ModuleLoader.Abstractions.csproj", "{F94A14A9-38B2-4573-A74B-979044428152}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MorganStanley.ComposeUI.ModuleLoader", "..\..\module-loader\dotnet\src\MorganStanley.ComposeUI.ModuleLoader\MorganStanley.ComposeUI.ModuleLoader.csproj", "{1D387A56-DC64-4EB0-870D-3872BE4716B4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -61,6 +65,14 @@ Global {70EAC402-B711-4528-B4DE-34081EB8676E}.Debug|Any CPU.Build.0 = Debug|Any CPU {70EAC402-B711-4528-B4DE-34081EB8676E}.Release|Any CPU.ActiveCfg = Release|Any CPU {70EAC402-B711-4528-B4DE-34081EB8676E}.Release|Any CPU.Build.0 = Release|Any CPU + {F94A14A9-38B2-4573-A74B-979044428152}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F94A14A9-38B2-4573-A74B-979044428152}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F94A14A9-38B2-4573-A74B-979044428152}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F94A14A9-38B2-4573-A74B-979044428152}.Release|Any CPU.Build.0 = Release|Any CPU + {1D387A56-DC64-4EB0-870D-3872BE4716B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1D387A56-DC64-4EB0-870D-3872BE4716B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1D387A56-DC64-4EB0-870D-3872BE4716B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1D387A56-DC64-4EB0-870D-3872BE4716B4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -73,6 +85,8 @@ Global {7A94BC15-8FE5-4C84-8572-3C72248AFF8C} = {E7A2C581-4BF4-47A5-8A11-59B2DEBADCA7} {3153E575-513F-4B1A-86D0-CBE5A7A8C606} = {E7A2C581-4BF4-47A5-8A11-59B2DEBADCA7} {70EAC402-B711-4528-B4DE-34081EB8676E} = {E7A2C581-4BF4-47A5-8A11-59B2DEBADCA7} + {F94A14A9-38B2-4573-A74B-979044428152} = {E7A2C581-4BF4-47A5-8A11-59B2DEBADCA7} + {1D387A56-DC64-4EB0-870D-3872BE4716B4} = {E7A2C581-4BF4-47A5-8A11-59B2DEBADCA7} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4C901E6C-4B9A-48C2-AB16-461040DC57B4} diff --git a/src/shell/dotnet/Shell.sln.DotSettings b/src/shell/dotnet/Shell.sln.DotSettings new file mode 100644 index 000000000..e3d9e1fe1 --- /dev/null +++ b/src/shell/dotnet/Shell.sln.DotSettings @@ -0,0 +1,12 @@ + + Morgan Stanley makes this available to you under the Apache License, +Version 2.0 (the "License"). You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0. + +See the NOTICE file distributed with this work for additional information +regarding copyright ownership. Unless required by applicable law or agreed +to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +or implied. See the License for the specific language governing permissions +and limitations under the License. \ No newline at end of file diff --git a/src/shell/dotnet/Shell/Abstractions/IInitializeAsync.cs b/src/shell/dotnet/Shell/Abstractions/IInitializeAsync.cs new file mode 100644 index 000000000..4da2ff907 --- /dev/null +++ b/src/shell/dotnet/Shell/Abstractions/IInitializeAsync.cs @@ -0,0 +1,20 @@ +// Morgan Stanley makes this available to you under the Apache License, +// Version 2.0 (the "License"). You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0. +// +// See the NOTICE file distributed with this work for additional information +// regarding copyright ownership. Unless required by applicable law or agreed +// to in writing, software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System.Threading.Tasks; + +namespace MorganStanley.ComposeUI.Shell.Abstractions; + +public interface IInitializeAsync +{ + Task InitializeAsync(); +} \ No newline at end of file diff --git a/src/shell/dotnet/Shell/App.xaml.cs b/src/shell/dotnet/Shell/App.xaml.cs index c5b3501f8..b42d3cfc8 100644 --- a/src/shell/dotnet/Shell/App.xaml.cs +++ b/src/shell/dotnet/Shell/App.xaml.cs @@ -13,8 +13,8 @@ // */ using System; -using System.Text.Json; -using System.Threading; +using System.Diagnostics; +using System.Linq; using System.Threading.Tasks; using System.Windows; using Microsoft.Extensions.Configuration; @@ -22,12 +22,13 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Logging.Configuration; -using Microsoft.Extensions.Options; using MorganStanley.ComposeUI.Fdc3.AppDirectory; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.DependencyInjection; -using MorganStanley.ComposeUI.Messaging.Server.WebSocket; +using MorganStanley.ComposeUI.ModuleLoader; +using MorganStanley.ComposeUI.Shell.Abstractions; using MorganStanley.ComposeUI.Shell.Fdc3; +using MorganStanley.ComposeUI.Shell.Messaging; +using MorganStanley.ComposeUI.Shell.Modules; using MorganStanley.ComposeUI.Shell.Utilities; namespace MorganStanley.ComposeUI.Shell; @@ -55,7 +56,22 @@ public TWindow CreateWindow(params object[] parameters) where TWindow : { Dispatcher.VerifyAccess(); - return ActivatorUtilities.CreateInstance(Host.Services, parameters); + return CreateInstance(parameters); + } + + public T? GetService() + { + return Host.Services.GetService(); + } + + public T GetRequiredService() where T : notnull + { + return Host.Services.GetRequiredService(); + } + + public T CreateInstance(params object[] parameters) + { + return ActivatorUtilities.CreateInstance(Host.Services, parameters); } protected override void OnStartup(StartupEventArgs e) @@ -67,16 +83,17 @@ protected override void OnStartup(StartupEventArgs e) protected override void OnExit(ExitEventArgs e) { base.OnExit(e); - var stopCompletedEvent = new ManualResetEventSlim(); - _ = Task.Run(() => StopAsync(stopCompletedEvent)); - _logger.LogDebug("Waiting for async shutdown"); - stopCompletedEvent.Wait(); - _logger.LogDebug("Async shutdown completed, application will now exit"); + Debug.WriteLine("Waiting for async shutdown"); + Task.Run(StopAsync).WaitOnDispatcher(); + Debug.WriteLine("Async shutdown completed, the application will now exit"); } private IHost? _host; + private ILogger _logger = NullLogger.Instance; - private string _messageRouterAccessToken = Guid.NewGuid().ToString("N"); + + // TODO: Assign a unique token for each module + internal readonly string MessageRouterAccessToken = Guid.NewGuid().ToString("N"); private async Task StartAsync(StartupEventArgs e) { @@ -101,12 +118,16 @@ private async Task StartAsync(StartupEventArgs e) private void ConfigureServices(HostBuilderContext context, IServiceCollection services) { + services.AddSingleton(this); + services.AddHttpClient(); services.Configure(context.Configuration.GetSection("Logging")); ConfigureMessageRouter(); + ConfigureModules(); + ConfigureFdc3(); void ConfigureMessageRouter() @@ -118,15 +139,27 @@ void ConfigureMessageRouter() .UseAccessTokenValidator( (clientId, token) => { - // TODO: Assign a separate token for each client and only allow a single connection with each token - if (_messageRouterAccessToken != token) + if (MessageRouterAccessToken != token) throw new InvalidOperationException("The provided access token is invalid"); })); services.AddMessageRouter( mr => mr .UseServer() - .UseAccessToken(_messageRouterAccessToken)); + .UseAccessToken(MessageRouterAccessToken)); + + services.AddTransient(); + } + + void ConfigureModules() + { + services.AddModuleLoader(); + services.AddSingleton(); + services.AddSingleton(p => p.GetRequiredService()); + services.AddSingleton(p => p.GetRequiredService()); + services.Configure( + context.Configuration.GetSection(ModuleCatalogOptions.ConfigurationPath)); + services.AddHostedService(); } void ConfigureFdc3() @@ -141,21 +174,25 @@ void ConfigureFdc3() services.AddFdc3AppDirectory(); services.Configure(fdc3ConfigurationSection); - services.Configure(fdc3ConfigurationSection.GetSection(nameof(fdc3Options.DesktopAgent))); - services.Configure(fdc3ConfigurationSection.GetSection(nameof(fdc3Options.AppDirectory))); + services.Configure( + fdc3ConfigurationSection.GetSection(nameof(fdc3Options.DesktopAgent))); + services.Configure( + fdc3ConfigurationSection.GetSection(nameof(fdc3Options.AppDirectory))); + + services.AddTransient(); } } } - // TODO: Extensibility: Plugins should be notified here. // Add any feature-specific async init code that depends on a running Host to this method private async Task OnHostInitializedAsync() { - InjectMessageRouterConfig(); - - var fdc3Options = Host.Services.GetRequiredService>(); - - if (fdc3Options.Value.EnableFdc3) InjectFdc3(); + await Task.WhenAll( + Host.Services.GetServices() + .Select( + i => i.InitializeAsync())); + // TODO: Not sure how to deal with exceptions here. + // The safest is probably to log and crash the whole app, since we cannot know which component just went defunct. } private void OnAsyncStartupCompleted(StartupEventArgs e) @@ -173,15 +210,32 @@ private void OnAsyncStartupCompleted(StartupEventArgs e) CreateWindow().Show(); } - private async Task StopAsync(ManualResetEventSlim stopCompletedEvent) + private async Task StopAsync() { - if (_host != null) + try { - await _host.StopAsync(); - _host.Dispose(); + if (_host != null) + { + await _host.StopAsync(); + _host.Dispose(); + } + } + catch (Exception e) + { + try + { + _logger.LogError( + e, + "Exception thrown while stopping the generic host: {ExceptionType}", + e.GetType().FullName); + } + catch + { + // In case the logger is already disposed at this point + Debug.WriteLine( + $"Exception thrown while stopping the generic host: {e.GetType().FullName}: {e.Message}"); + } } - - stopCompletedEvent.Set(); } private void StartWithWebWindowOptions(WebWindowOptions options) @@ -189,30 +243,4 @@ private void StartWithWebWindowOptions(WebWindowOptions options) ShutdownMode = ShutdownMode.OnLastWindowClose; CreateWindow(options).Show(); } - - private void InjectMessageRouterConfig() - { - var server = Host.Services.GetRequiredService(); - _logger.LogInformation($"Message Router server listening at {server.WebSocketUrl}"); - - WebWindow.AddPreloadScript( - $$""" - window.composeui = { - ...window.composeui, - messageRouterConfig: { - accessToken: "{{JsonEncodedText.Encode(_messageRouterAccessToken)}}", - webSocket: { - url: "{{server.WebSocketUrl}}" - } - } - }; - - """); - } - - private void InjectFdc3() - { - var iife = ResourceReader.ReadResource(ResourceNames.Fdc3Bundle); - WebWindow.AddPreloadScript(iife); - } } \ No newline at end of file diff --git a/src/shell/dotnet/Shell/Fdc3/Fdc3Options.cs b/src/shell/dotnet/Shell/Fdc3/Fdc3Options.cs index a0db5f34d..b7f76b664 100644 --- a/src/shell/dotnet/Shell/Fdc3/Fdc3Options.cs +++ b/src/shell/dotnet/Shell/Fdc3/Fdc3Options.cs @@ -1,26 +1,38 @@ -using Microsoft.Extensions.Options; +// Morgan Stanley makes this available to you under the Apache License, +// Version 2.0 (the "License"). You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0. +// +// See the NOTICE file distributed with this work for additional information +// regarding copyright ownership. Unless required by applicable law or agreed +// to in writing, software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using Microsoft.Extensions.Options; using MorganStanley.ComposeUI.Fdc3.AppDirectory; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.DependencyInjection; namespace MorganStanley.ComposeUI.Shell.Fdc3; /// -/// Configuration root for FDC3 features. This object is configured under the FDC3 section. +/// Configuration root for FDC3 features. This object is configured under the FDC3 section. /// public class Fdc3Options : IOptions { /// - /// When set to true, it will enable Fdc3 backend service. + /// When set to true, it will enable the Fdc3 backend service. /// public bool EnableFdc3 { get; set; } /// - /// Options for the FDC3 Desktop Agent + /// Options for the FDC3 Desktop Agent /// public Fdc3DesktopAgentOptions DesktopAgent { get; set; } = new(); /// - /// Options for the FDC3 App Directory + /// Options for the FDC3 App Directory /// public AppDirectoryOptions AppDirectory { get; set; } = new(); diff --git a/src/shell/dotnet/Shell/Fdc3/Fdc3StartupAction.cs b/src/shell/dotnet/Shell/Fdc3/Fdc3StartupAction.cs new file mode 100644 index 000000000..ffcc4eb47 --- /dev/null +++ b/src/shell/dotnet/Shell/Fdc3/Fdc3StartupAction.cs @@ -0,0 +1,32 @@ +// Morgan Stanley makes this available to you under the Apache License, +// Version 2.0 (the "License"). You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0. +// +// See the NOTICE file distributed with this work for additional information +// regarding copyright ownership. Unless required by applicable law or agreed +// to in writing, software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System; +using System.Threading.Tasks; +using MorganStanley.ComposeUI.ModuleLoader; +using MorganStanley.ComposeUI.Shell.Utilities; + +namespace MorganStanley.ComposeUI.Shell.Fdc3; + +internal sealed class Fdc3StartupAction : IStartupAction +{ + public Task InvokeAsync(StartupContext startupContext, Func next) + { + if (startupContext.ModuleInstance.Manifest.ModuleType == ModuleType.Web) + { + startupContext.GetOrAddProperty() + .ScriptProviders.Add(_ => new ValueTask(ResourceReader.ReadResource(ResourceNames.Fdc3Bundle))); + } + + return next(); + } +} \ No newline at end of file diff --git a/src/shell/dotnet/Shell/MainWindow.xaml b/src/shell/dotnet/Shell/MainWindow.xaml index bdbe80014..b77b1f73e 100644 --- a/src/shell/dotnet/Shell/MainWindow.xaml +++ b/src/shell/dotnet/Shell/MainWindow.xaml @@ -15,7 +15,11 @@ See the License for the specific language governing permissions and limitations xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:MorganStanley.ComposeUI.Shell" mc:Ignorable="d" - Title="Compose" Height="450" Width="600"> + Title="ComposeUI Shell" + Height="450" + Width="600" + Background="{DynamicResource {x:Static SystemColors.AppWorkspaceBrushKey}}" + WindowStartupLocation="CenterScreen"> @@ -28,17 +32,15 @@ See the License for the specific language governing permissions and limitations - + diff --git a/src/shell/dotnet/Shell/MainWindow.xaml.cs b/src/shell/dotnet/Shell/MainWindow.xaml.cs index 82ae4de45..7cab1bbeb 100644 --- a/src/shell/dotnet/Shell/MainWindow.xaml.cs +++ b/src/shell/dotnet/Shell/MainWindow.xaml.cs @@ -1,69 +1,97 @@ -// /* -// * Morgan Stanley makes this available to you under the Apache License, -// * Version 2.0 (the "License"). You may obtain a copy of the License at -// * -// * http://www.apache.org/licenses/LICENSE-2.0. -// * -// * See the NOTICE file distributed with this work for additional information -// * regarding copyright ownership. Unless required by applicable law or agreed -// * to in writing, software distributed under the License is distributed on an -// * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express -// * or implied. See the License for the specific language governing permissions -// * and limitations under the License. -// */ +// Morgan Stanley makes this available to you under the Apache License, +// Version 2.0 (the "License"). You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0. +// +// See the NOTICE file distributed with this work for additional information +// regarding copyright ownership. Unless required by applicable law or agreed +// to in writing, software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions +// and limitations under the License. -using MorganStanley.ComposeUI.Shell.Manifest; -using System; -using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; using System.Windows; -using System.Windows.Controls; using System.Windows.Controls.Ribbon; +using CommunityToolkit.Mvvm.ComponentModel; +using MorganStanley.ComposeUI.ModuleLoader; +using MorganStanley.ComposeUI.Shell.ImageSource; namespace MorganStanley.ComposeUI.Shell; /// -/// Interaction logic for MainWindow.xaml +/// Interaction logic for MainWindow.xaml /// public partial class MainWindow : RibbonWindow { - internal List WebWindows { get; set; } = new List(); - private ManifestModel _config; - private ModuleModel[]? _modules; - - public MainWindow() + public MainWindow( + IModuleCatalog moduleCatalog, + IModuleLoader moduleLoader, + IImageSourcePolicy? imageSourcePolicy = null) { InitializeComponent(); + _moduleLoader = moduleLoader; + var iconProvider = new ImageSourceProvider(imageSourcePolicy ?? new DefaultImageSourcePolicy()); + + ViewModel = new MainWindowViewModel + { + Modules = new ObservableCollection( + moduleCatalog.GetModuleIds() + .Select(id => new ModuleViewModel(moduleCatalog.GetManifest(id), iconProvider))) + }; + } - _config = ManifestParser.OpenManifestFile("exampleManifest.json"); - _modules = _config.Modules; - DataContext = _modules; + internal MainWindowViewModel ViewModel + { + get => (MainWindowViewModel) DataContext; + private set => DataContext = value; } - private void CreateWebWindow(ModuleModel item) + private readonly IModuleLoader _moduleLoader; + + private async void StartModule_Click(object sender, RoutedEventArgs e) { - var options = new WebWindowOptions + // I ❤️ C# + if (sender is FrameworkElement + { + DataContext: ModuleViewModel module + }) { - Title = item.AppName, - Url = item.Url, - IconUrl = item.IconUrl - }; - - var webWindow = new WebWindow(options); - webWindow.Owner = this; - webWindow.Closed += WebWindowClosed; - WebWindows.Add(webWindow); - webWindow.Show(); + await _moduleLoader.StartModule(new StartRequest(module.Manifest.Id)); + } } - private void WebWindowClosed(object? sender, EventArgs e) + internal sealed class MainWindowViewModel : ObservableObject { - WebWindows.Remove((WebWindow)sender!); + public ObservableCollection Modules + { + get => _modules; + set => SetProperty(ref _modules, value); + } + + private ObservableCollection _modules = new(); } - - private void ShowChild_Click(object sender, RoutedEventArgs e) + + internal sealed class ModuleViewModel { - var context = ((Button)sender).DataContext; - - CreateWebWindow((ModuleModel)context); + public ModuleViewModel(IModuleManifest manifest, ImageSourceProvider imageSourceProvider) + { + Manifest = manifest; + + if (manifest.TryGetDetails(out var webManifestDetails)) + { + if (webManifestDetails.IconUrl != null) + { + ImageSource = imageSourceProvider.GetImageSource( + webManifestDetails.IconUrl, + webManifestDetails.Url); + } + } + } + + public IModuleManifest Manifest { get; } + + public System.Windows.Media.ImageSource? ImageSource { get; } } -} +} \ No newline at end of file diff --git a/src/shell/dotnet/Shell/Manifest/ManifestModel.cs b/src/shell/dotnet/Shell/Manifest/ManifestModel.cs deleted file mode 100644 index 1869ed559..000000000 --- a/src/shell/dotnet/Shell/Manifest/ManifestModel.cs +++ /dev/null @@ -1,30 +0,0 @@ -// /* -// * Morgan Stanley makes this available to you under the Apache License, -// * Version 2.0 (the "License"). You may obtain a copy of the License at -// * -// * http://www.apache.org/licenses/LICENSE-2.0. -// * -// * See the NOTICE file distributed with this work for additional information -// * regarding copyright ownership. Unless required by applicable law or agreed -// * to in writing, software distributed under the License is distributed on an -// * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express -// * or implied. See the License for the specific language governing permissions -// * and limitations under the License. -// */ - -using System.Text.Json; - -namespace MorganStanley.ComposeUI.Shell.Manifest; - -internal sealed class ManifestModel -{ - public ModuleModel[]? Modules { get; set; } - - public static JsonSerializerOptions JsonSerializerOptions = new() - { - PropertyNameCaseInsensitive = true - }; -} - - - diff --git a/src/shell/dotnet/Shell/Manifest/ManifestParser.cs b/src/shell/dotnet/Shell/Manifest/ManifestParser.cs deleted file mode 100644 index 196dac310..000000000 --- a/src/shell/dotnet/Shell/Manifest/ManifestParser.cs +++ /dev/null @@ -1,40 +0,0 @@ -// /* -// * Morgan Stanley makes this available to you under the Apache License, -// * Version 2.0 (the "License"). You may obtain a copy of the License at -// * -// * http://www.apache.org/licenses/LICENSE-2.0. -// * -// * See the NOTICE file distributed with this work for additional information -// * regarding copyright ownership. Unless required by applicable law or agreed -// * to in writing, software distributed under the License is distributed on an -// * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express -// * or implied. See the License for the specific language governing permissions -// * and limitations under the License. -// */ - -using System; -using System.IO; -using System.Text.Json; - -namespace MorganStanley.ComposeUI.Shell.Manifest; - -internal static class ManifestParser -{ - internal static ManifestModel Manifest { get; set; } - - public static ManifestModel OpenManifestFile(string fileName) - { - var processPath = Environment.ProcessPath; - var folder = Path.GetDirectoryName(processPath); - var path = Path.Combine(folder, @"Manifest\", fileName); - - using (var stream = File.Open(path, FileMode.Open)) - { - Manifest = JsonSerializer.Deserialize(stream, ManifestModel.JsonSerializerOptions); - - stream.Close(); - } - - return Manifest; - } -} diff --git a/src/shell/dotnet/Shell/Manifest/ModuleModel.cs b/src/shell/dotnet/Shell/Manifest/ModuleModel.cs deleted file mode 100644 index b0d323ab0..000000000 --- a/src/shell/dotnet/Shell/Manifest/ModuleModel.cs +++ /dev/null @@ -1,25 +0,0 @@ -// /* -// * Morgan Stanley makes this available to you under the Apache License, -// * Version 2.0 (the "License"). You may obtain a copy of the License at -// * -// * http://www.apache.org/licenses/LICENSE-2.0. -// * -// * See the NOTICE file distributed with this work for additional information -// * regarding copyright ownership. Unless required by applicable law or agreed -// * to in writing, software distributed under the License is distributed on an -// * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express -// * or implied. See the License for the specific language governing permissions -// * and limitations under the License. -// */ - -using System; - -namespace MorganStanley.ComposeUI.Shell.Manifest; - -[Serializable] -internal sealed class ModuleModel -{ - public string AppName { get; set; } = string.Empty; - public string Url { get; set; } = string.Empty; - public string IconUrl { get; set; } = string.Empty; -} diff --git a/src/shell/dotnet/Shell/Manifest/exampleManifest.json b/src/shell/dotnet/Shell/Manifest/exampleManifest.json deleted file mode 100644 index 5349d2bd6..000000000 --- a/src/shell/dotnet/Shell/Manifest/exampleManifest.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "modules": [ - { - "appName": "Morgan Stanley", - "url": "http://www.morganstanley.com" - }, - { - "appName": "Microsoft", - "url": "http://www.microsoft.com" - }, - { - "appName": "Google", - "url": "http://www.google.com" - }, - { - "appName": "FINOS FDC3 Workbench", - "url": "https://fdc3.finos.org/toolbox/fdc3-workbench/" - } - ] -} diff --git a/src/shell/dotnet/Shell/Messaging/MessageRouterStartupAction.cs b/src/shell/dotnet/Shell/Messaging/MessageRouterStartupAction.cs new file mode 100644 index 000000000..f5c4e36fe --- /dev/null +++ b/src/shell/dotnet/Shell/Messaging/MessageRouterStartupAction.cs @@ -0,0 +1,57 @@ +// Morgan Stanley makes this available to you under the Apache License, +// Version 2.0 (the "License"). You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0. +// +// See the NOTICE file distributed with this work for additional information +// regarding copyright ownership. Unless required by applicable law or agreed +// to in writing, software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System; +using System.Text.Json; +using System.Threading.Tasks; +using MorganStanley.ComposeUI.Messaging.Server.WebSocket; +using MorganStanley.ComposeUI.ModuleLoader; + +namespace MorganStanley.ComposeUI.Shell.Messaging; + +internal sealed class MessageRouterStartupAction : IStartupAction +{ + private readonly IMessageRouterWebSocketServer? _webSocketServer; + + public MessageRouterStartupAction(IMessageRouterWebSocketServer? webSocketServer = null) + { + _webSocketServer = webSocketServer; + } + + public Task InvokeAsync(StartupContext startupContext, Func next) + { + if (startupContext.ModuleInstance.Manifest.ModuleType == ModuleType.Web) + { + if (_webSocketServer != null) + { + var webProperties = startupContext.GetOrAddProperty(); + + webProperties.ScriptProviders.Add( + _ => new ValueTask( + $$""" + window.composeui = { + ...window.composeui, + messageRouterConfig: { + accessToken: "{{JsonEncodedText.Encode(App.Current.MessageRouterAccessToken)}}", + webSocket: { + url: "{{_webSocketServer.WebSocketUrl}}" + } + } + }; + """)); + } + } + + + return next(); + } +} \ No newline at end of file diff --git a/src/shell/dotnet/Shell/Modules/ModuleCatalog.cs b/src/shell/dotnet/Shell/Modules/ModuleCatalog.cs new file mode 100644 index 000000000..af2c6307b --- /dev/null +++ b/src/shell/dotnet/Shell/Modules/ModuleCatalog.cs @@ -0,0 +1,136 @@ +// Morgan Stanley makes this available to you under the Apache License, +// Version 2.0 (the "License"). You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0. +// +// See the NOTICE file distributed with this work for additional information +// regarding copyright ownership. Unless required by applicable law or agreed +// to in writing, software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System; +using System.Collections.Generic; +using System.IO.Abstractions; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using MorganStanley.ComposeUI.ModuleLoader; +using MorganStanley.ComposeUI.Shell.Abstractions; + +namespace MorganStanley.ComposeUI.Shell.Modules; + +internal sealed class ModuleCatalog : IModuleCatalog, IInitializeAsync +{ + public ModuleCatalog(IOptions options, IFileSystem? fileSystem = null) + { + _options = options.Value; + _fileSystem = fileSystem ?? new FileSystem(); + } + + public async Task InitializeAsync() + { + if (_options.CatalogUrl == null) + return; + + if (_options.CatalogUrl.Scheme != "file") + throw new InvalidOperationException( + "Cannot load the module catalog from the provided URL. Only local files are supported."); + + await LoadFromFile(_options.CatalogUrl.LocalPath); + } + + public IModuleManifest GetManifest(string moduleId) + { + return _modules[moduleId]; + } + + public IEnumerable GetModuleIds() + { + return _modules.Keys; + } + + private readonly IFileSystem _fileSystem; + private readonly ModuleCatalogOptions _options; + private Dictionary _modules = new(); + + private async Task LoadFromFile(string path) + { + await using var stream = _fileSystem.File.OpenRead(path); + _modules = (await JsonSerializer.DeserializeAsync( + stream, + JsonSerializerOptions)) + ?.ToDictionary(m => m.Id) + ?? new Dictionary(); + } + + private void Add(ModuleManifest manifest) + { + _modules.Add(manifest.Id, manifest); + } + + private static readonly JsonSerializerOptions JsonSerializerOptions = + new() {Converters = {new ModuleManifestConverter()}}; + + private class ModuleManifest : IModuleManifest + { + public string Id { get; set; } + public string Name { get; set; } + public string ModuleType { get; set; } + } + + private class WebModuleManifest : ModuleManifest, IModuleManifest + { + public WebManifestDetails GetDetails() + { + return Details; + } + + public WebManifestDetails Details { get; set; } + } + + private class ModuleManifestConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return objectType == typeof(ModuleManifest); + } + + public override ModuleManifest? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + var typeReader = reader; + var header = JsonSerializer.Deserialize(ref typeReader, options); + + switch (header.ModuleType) + { + case ModuleType.Web: + return JsonSerializer.Deserialize(ref reader, options); + } + + throw new InvalidOperationException("Unsupported module type: " + header.ModuleType); + } + + public override void Write(Utf8JsonWriter writer, ModuleManifest value, JsonSerializerOptions options) + { + if (value is WebModuleManifest webModuleManifest) + { + JsonSerializer.Serialize(writer, webModuleManifest, options); + + return; + } + + JsonSerializer.Serialize(writer, value as IModuleManifest, options); + } + + private struct ManifestTypeHelper + { + public string ModuleType { get; set; } + } + } +} \ No newline at end of file diff --git a/src/shell/dotnet/Shell/Modules/ModuleCatalogOptions.cs b/src/shell/dotnet/Shell/Modules/ModuleCatalogOptions.cs new file mode 100644 index 000000000..5c6ca5202 --- /dev/null +++ b/src/shell/dotnet/Shell/Modules/ModuleCatalogOptions.cs @@ -0,0 +1,25 @@ +// Morgan Stanley makes this available to you under the Apache License, +// Version 2.0 (the "License"). You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0. +// +// See the NOTICE file distributed with this work for additional information +// regarding copyright ownership. Unless required by applicable law or agreed +// to in writing, software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System; +using Microsoft.Extensions.Options; + +namespace MorganStanley.ComposeUI.Shell.Modules; + +internal sealed class ModuleCatalogOptions : IOptions +{ + public Uri? CatalogUrl { get; set; } + + public ModuleCatalogOptions Value => this; + + public static readonly string ConfigurationPath = "ModuleCatalog"; +} \ No newline at end of file diff --git a/src/shell/dotnet/Shell/Modules/ModuleService.cs b/src/shell/dotnet/Shell/Modules/ModuleService.cs new file mode 100644 index 000000000..1546f5df9 --- /dev/null +++ b/src/shell/dotnet/Shell/Modules/ModuleService.cs @@ -0,0 +1,88 @@ +// Morgan Stanley makes this available to you under the Apache License, +// Version 2.0 (the "License"). You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0. +// +// See the NOTICE file distributed with this work for additional information +// regarding copyright ownership. Unless required by applicable law or agreed +// to in writing, software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MorganStanley.ComposeUI.ModuleLoader; + +namespace MorganStanley.ComposeUI.Shell.Modules; + +internal sealed class ModuleService : IHostedService +{ + private readonly App _application; + private readonly IModuleLoader _moduleLoader; + private ConcurrentBag _disposables = new(); + private readonly ILogger _logger; + + public ModuleService(App application, IModuleLoader moduleLoader, ILogger? logger = null) + { + _application = application; + _moduleLoader = moduleLoader; + _logger = logger ?? NullLogger.Instance; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _disposables.Add( + _moduleLoader.LifetimeEvents + .OfType() + .Where(e => e.Instance.Manifest.ModuleType == ModuleType.Web) + .Subscribe(OnWebModuleStarted)); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + foreach (var disposable in _disposables) + { + (disposable as IDisposable)?.Dispose(); + } + + return Task.CompletedTask; + } + + private async void OnWebModuleStarted(LifetimeEvent.Started e) + { + var properties = e.Instance.GetProperties().OfType().FirstOrDefault(); + + if (properties == null) return; + + try + { + await _application.Dispatcher.InvokeAsync( + () => + { + var window = _application.CreateWindow( + e.Instance, + new WebWindowOptions + { + Url = properties.Url.ToString(), + IconUrl = properties.IconUrl?.ToString() + }); + + window.Show(); + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception thrown when trying to create a web window: {ExceptionType}: {ExceptionMessage}", ex.GetType().FullName, ex.Message); + } + } +} \ No newline at end of file diff --git a/src/shell/dotnet/Shell/Properties/launchSettings.json b/src/shell/dotnet/Shell/Properties/launchSettings.json index c40f1522a..5b83b62de 100644 --- a/src/shell/dotnet/Shell/Properties/launchSettings.json +++ b/src/shell/dotnet/Shell/Properties/launchSettings.json @@ -1,8 +1,5 @@ { "profiles": { - "Shell": { - "commandName": "Project" - }, "Chart and grid": { "commandName": "Project", "commandLineArgs": "--url http://localhost:4200\r\n--FDC3:AppDirectory:Source $(ComposeUIRepositoryRoot)/examples/fdc3-appdirectory/apps.json" @@ -10,6 +7,10 @@ "FINOS FDC3 Workbench": { "commandName": "Project", "commandLineArgs": "--url https://fdc3.finos.org/toolbox/fdc3-workbench/" + }, + "Shell": { + "commandName": "Project", + "commandLineArgs": "--ModuleCatalog:CatalogUrl \"file:///$(ProjectDir)..\\examples\\module-catalog.json\"" } } } \ No newline at end of file diff --git a/src/shell/dotnet/Shell/Shell.csproj b/src/shell/dotnet/Shell/Shell.csproj index d5896fd27..1b99faea9 100644 --- a/src/shell/dotnet/Shell/Shell.csproj +++ b/src/shell/dotnet/Shell/Shell.csproj @@ -17,6 +17,7 @@ + @@ -34,15 +35,14 @@ PreserveNewest - - Always - + + @@ -53,6 +53,10 @@ + + + + @@ -65,4 +69,6 @@ + + \ No newline at end of file diff --git a/src/shell/dotnet/Shell/Utilities/TaskExtensions.cs b/src/shell/dotnet/Shell/Utilities/TaskExtensions.cs new file mode 100644 index 000000000..2fa9e0fa1 --- /dev/null +++ b/src/shell/dotnet/Shell/Utilities/TaskExtensions.cs @@ -0,0 +1,41 @@ +// Morgan Stanley makes this available to you under the Apache License, +// Version 2.0 (the "License"). You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0. +// +// See the NOTICE file distributed with this work for additional information +// regarding copyright ownership. Unless required by applicable law or agreed +// to in writing, software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System.Threading.Tasks; +using System.Windows.Threading; + +namespace MorganStanley.ComposeUI.Shell.Utilities; + +public static class TaskExtensions +{ + /// + /// Synchronously waits for the task to complete, while keeping the UI thread responsive. + /// + /// + public static void WaitOnDispatcher(this Task task) + { + if (task.IsCompleted) + { + task.Wait(); + + return; + } + + var frame = new DispatcherFrame(exitWhenRequested: true); + + _ = task.ContinueWith(_ => frame.Continue = false); + + Dispatcher.PushFrame(frame); + + task.Wait(); // Propagate exceptions + } +} \ No newline at end of file diff --git a/src/shell/dotnet/Shell/WebWindow.xaml b/src/shell/dotnet/Shell/WebWindow.xaml index 3f23e062f..b75365c42 100644 --- a/src/shell/dotnet/Shell/WebWindow.xaml +++ b/src/shell/dotnet/Shell/WebWindow.xaml @@ -10,9 +10,7 @@ > - + diff --git a/src/shell/dotnet/Shell/WebWindow.xaml.cs b/src/shell/dotnet/Shell/WebWindow.xaml.cs index 735ee6e30..a9a5a90a6 100644 --- a/src/shell/dotnet/Shell/WebWindow.xaml.cs +++ b/src/shell/dotnet/Shell/WebWindow.xaml.cs @@ -1,10 +1,18 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading; using System.Threading.Tasks; using System.Windows; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Web.WebView2.Core; +using MorganStanley.ComposeUI.ModuleLoader; using MorganStanley.ComposeUI.Shell.ImageSource; +using MorganStanley.ComposeUI.Shell.Utilities; namespace MorganStanley.ComposeUI.Shell; @@ -13,9 +21,18 @@ namespace MorganStanley.ComposeUI.Shell; /// public partial class WebWindow : Window { - public WebWindow(WebWindowOptions options) + public WebWindow( + WebWindowOptions options, + IModuleLoader moduleLoader, + IModuleInstance? moduleInstance = null, + ILogger? logger = null, + IImageSourcePolicy? imageSourcePolicy = null) { + _moduleLoader = moduleLoader; + _moduleInstance = moduleInstance; + _iconProvider = new ImageSourceProvider(imageSourcePolicy ?? new DefaultImageSourcePolicy()); _options = options; + _logger = logger ?? NullLogger.Instance; InitializeComponent(); // TODO: When no title is set from options, we should show the HTML document's title instead @@ -24,35 +41,90 @@ public WebWindow(WebWindowOptions options) Height = options.Height ?? WebWindowOptions.DefaultHeight; TrySetIconUrl(options); + if (moduleInstance != null) + { + DisposeWhenClosed( + _moduleLoader.LifetimeEvents + .Where( + e => e.Instance == moduleInstance + && e.EventType is LifetimeEventType.Stopping or LifetimeEventType.Stopped) + .ObserveOn(SynchronizationContext.Current!) + .Subscribe( + Observer.Create( + (LifetimeEvent e) => + { + _lifetimeEvent = e.EventType; + Close(); + }))); + } + _ = InitializeAsync(); } - public static void AddPreloadScript(string script) + public IModuleInstance? ModuleInstance => _moduleInstance; + + protected override void OnClosing(CancelEventArgs args) { - PreloadScripts.Add(script); + // TODO: Send the closing event to the page, allow it to cancel + + if (_moduleInstance == null) + return; + + switch (_lifetimeEvent) + { + case LifetimeEventType.Stopped: + return; + + case LifetimeEventType.Stopping: + args.Cancel = true; + Hide(); + return; + + default: + args.Cancel = true; + Hide(); + Task.Run(() => _moduleLoader.StopModule(new StopRequest(_moduleInstance.InstanceId))); + return; + } } protected override void OnClosed(EventArgs e) { base.OnClosed(e); - RemoveLogicalChild(webView); - webView.Dispose(); - } + RemoveLogicalChild(WebView); + WebView.Dispose(); + + var disposables = _disposables.AsEnumerable().Reverse().ToArray(); + _disposables.Clear(); - private static readonly HashSet PreloadScripts = new(); + foreach (var disposable in disposables) + { + disposable.Dispose(); + } + } + private readonly IModuleLoader _moduleLoader; + private readonly IModuleInstance? _moduleInstance; private readonly WebWindowOptions _options; - private readonly ImageSourceProvider _iconProvider = new(new EnvironmentImageSourcePolicy()); + private readonly ILogger _logger; + private readonly ImageSourceProvider _iconProvider; private bool _scriptsInjected; + private LifetimeEventType _lifetimeEvent = LifetimeEventType.Started; private readonly TaskCompletionSource _scriptInjectionCompleted = new(); + private readonly List _disposables = new(); private async Task InitializeAsync() { - await webView.EnsureCoreWebView2Async(); - await InitializeCoreWebView(webView.CoreWebView2); + await WebView.EnsureCoreWebView2Async(); + await InitializeCoreWebView2(WebView.CoreWebView2); await LoadWebContentAsync(_options); } + private void DisposeWhenClosed(IDisposable disposable) + { + _disposables.Add(disposable); + } + private void TrySetIconUrl(WebWindowOptions webWindowOptions) { if (webWindowOptions.IconUrl == null) @@ -71,11 +143,13 @@ private void TrySetIconUrl(WebWindowOptions webWindowOptions) } } - private async Task InitializeCoreWebView(CoreWebView2 coreWebView) + private Task InitializeCoreWebView2(CoreWebView2 coreWebView) { coreWebView.NewWindowRequested += (sender, args) => OnNewWindowRequested(args); coreWebView.WindowCloseRequested += (sender, args) => OnWindowCloseRequested(args); coreWebView.NavigationStarting += (sender, args) => OnNavigationStarting(args); + + return Task.CompletedTask; } private void OnNavigationStarting(CoreWebView2NavigationStartingEventArgs args) @@ -88,15 +162,17 @@ private void OnNavigationStarting(CoreWebView2NavigationStartingEventArgs args) Dispatcher.InvokeAsync( async () => { - await InjectScriptsAsync(webView.CoreWebView2); + await InjectScriptsAsync(WebView.CoreWebView2); - webView.CoreWebView2.Navigate(args.Uri.ToString()); + WebView.CoreWebView2.Navigate(args.Uri); }); } - private async Task LoadWebContentAsync(WebWindowOptions options) + private Task LoadWebContentAsync(WebWindowOptions options) { - webView.Source = new Uri(options.Url ?? WebWindowOptions.DefaultUrl); + WebView.Source = new Uri(options.Url ?? WebWindowOptions.DefaultUrl); + + return Task.CompletedTask; } private async Task InjectScriptsAsync(CoreWebView2 coreWebView) @@ -105,7 +181,19 @@ private async Task InjectScriptsAsync(CoreWebView2 coreWebView) return; _scriptsInjected = true; - await Task.WhenAll(PreloadScripts.Select(coreWebView.AddScriptToExecuteOnDocumentCreatedAsync)); + var webProperties = _moduleInstance?.GetProperties().OfType().FirstOrDefault(); + + if (webProperties != null) + { + await Task.WhenAll( + webProperties.ScriptProviders.Select( + async scriptProvider => + { + var script = await scriptProvider(_moduleInstance!); + await coreWebView.AddScriptToExecuteOnDocumentCreatedAsync(script); + })); + } + _scriptInjectionCompleted.SetResult(); } @@ -114,7 +202,7 @@ private async void OnNewWindowRequested(CoreWebView2NewWindowRequestedEventArgs using var deferral = e.GetDeferral(); e.Handled = true; - var windowOptions = new WebWindowOptions { Url = e.Uri }; + var windowOptions = new WebWindowOptions {Url = e.Uri}; if (e.WindowFeatures.HasSize) { @@ -122,14 +210,24 @@ private async void OnNewWindowRequested(CoreWebView2NewWindowRequestedEventArgs windowOptions.Height = e.WindowFeatures.Height; } - var window = new WebWindow(windowOptions); + var constructorArgs = new List {windowOptions}; + + // For now, we only inject the module-specific information when the window was created + // in response to a start request. Later we might allow the page to open a new window + // and get the scripts preloaded if some conditions are met. + if (_moduleInstance != null) + { + constructorArgs.Add(_moduleInstance); + } + + var window = App.Current.CreateWindow(constructorArgs.ToArray()); window.Show(); - await window.webView.EnsureCoreWebView2Async(); - e.NewWindow = window.webView.CoreWebView2; + await window.WebView.EnsureCoreWebView2Async(); + e.NewWindow = window.WebView.CoreWebView2; } private void OnWindowCloseRequested(object args) { Close(); } -} +} \ No newline at end of file diff --git a/src/shell/dotnet/examples/module-catalog.json b/src/shell/dotnet/examples/module-catalog.json new file mode 100644 index 000000000..0da5fc893 --- /dev/null +++ b/src/shell/dotnet/examples/module-catalog.json @@ -0,0 +1,38 @@ +[ + { + "Id": "Morgan Stanley", + "Name": "Morgan Stanley", + "ModuleType": "web", + "Details": { + "Url": "https://www.morganstanley.com", + "IconUrl": "https://www.morganstanley.com/etc/designs/msdotcom/image/favicon-96x96.png" + } + }, + { + "Id": "Microsoft", + "Name": "Microsoft", + "ModuleType": "web", + "Details": { + "Url": "https://www.microsoft.com", + "IconUrl": "https://www.microsoft.com/favicon.ico?v2" + } + }, + { + "Id": "Google", + "Name": "Google", + "ModuleType": "web", + "Details": { + "Url": "https://www.google.com", + "IconUrl": "https://www.google.com/images/branding/googleg/1x/googleg_standard_color_128dp.png" + } + }, + { + "Id": "FINOS FDC3 Workbench", + "Name": "FINOS FDC3 Workbench", + "ModuleType": "web", + "Details": { + "Url": "https://fdc3.finos.org/toolbox/fdc3-workbench/", + "IconUrl": "https://fdc3.finos.org/toolbox/fdc3-workbench/favicon.ico" + } + } +] \ No newline at end of file