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
+
+
+
+
@@ -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