diff --git a/examples/dotnet-diagnostics/DiagnosticsExample/App.xaml.cs b/examples/dotnet-diagnostics/DiagnosticsExample/App.xaml.cs
index dacfd4562..6d1359d61 100644
--- a/examples/dotnet-diagnostics/DiagnosticsExample/App.xaml.cs
+++ b/examples/dotnet-diagnostics/DiagnosticsExample/App.xaml.cs
@@ -12,6 +12,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using System.Diagnostics;
using System.IO;
using System.Windows;
diff --git a/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml b/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml
index d30b967a9..bb44e9860 100644
--- a/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml
+++ b/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml
@@ -20,8 +20,27 @@ See the License for the specific language governing permissions and limitations
Loaded="Window_Loaded"
DataContext="{Binding RelativeSource={RelativeSource Self}}">
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml.cs b/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml.cs
index 1168b982e..fd066ed27 100644
--- a/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml.cs
+++ b/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml.cs
@@ -18,9 +18,8 @@
using System.Text.Json;
using System.Windows;
using Finos.Fdc3;
-using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol;
using Finos.Fdc3.Context;
-using System.CodeDom;
+using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier;
namespace DiagnosticsExample;
@@ -42,7 +41,11 @@ public string DiagnosticsText
DependencyProperty.Register("DiagnosticsText", typeof(string), typeof(MainWindow), new PropertyMetadata(string.Empty));
private readonly IDesktopAgent _desktopAgent;
- private IListener _subscription;
+ private IChannel _appChannel;
+ private IPrivateChannel? _privateChannel;
+ private IListener? _privateChannelContextListener;
+ private IListener? _subscription;
+ private IListener _listener;
public MainWindow()
{
@@ -81,80 +84,519 @@ private void Window_Loaded(object sender, RoutedEventArgs e)
private async Task LogDiagnostics()
{
- var diag = await _messaging!.InvokeJsonServiceAsync("Diagnostics", new JsonSerializerOptions { WriteIndented = true });
-
- if (diag == null)
- {
- return;
- }
-
- await Dispatcher.InvokeAsync(() => DiagnosticsText += diag.ToString());
+ var textBuilder = new StringBuilder();
- var result =
- await _desktopAgent.GetAppMetadata(new MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier()
- { AppId = "WPFExample" });
+ try
+ {
+ var diag = await _messaging!.InvokeJsonServiceAsync("Diagnostics", new JsonSerializerOptions { WriteIndented = true }).ConfigureAwait(false);
+ if (diag != null)
+ {
+ textBuilder.AppendLine(diag.ToString());
+ }
- await Dispatcher.InvokeAsync(() => DiagnosticsText += "\n" + result.Description);
+ var result = await _desktopAgent.GetAppMetadata(new AppIdentifier() { AppId = "WPFExample" }).ConfigureAwait(false);
+ textBuilder.AppendLine(result.Description);
+ }
+ catch (Exception ex)
+ {
+ textBuilder.AppendLine(ex.Message);
+ }
+ finally
+ {
+ await Dispatcher.InvokeAsync(() => DiagnosticsText += textBuilder.ToString());
+ }
}
private async void SubscribeButton_Click(object sender, RoutedEventArgs e)
{
+ var textBuilder = new StringBuilder();
+ DiagnosticsText = string.Empty;
+
try
{
- Dispatcher.Invoke(() => DiagnosticsText += "\n" + "Subscription is in working progress");
+ await Task.Run(async () =>
+ {
+ textBuilder.AppendLine("Subscription is in working progress");
+
+ await JoinToUserChannel().ConfigureAwait(false);
+
+ _subscription = await _desktopAgent.AddContextListener("fdc3.instrument",
+ (context, contextMetadata) =>
+ {
+ Dispatcher.Invoke(() =>
+ DiagnosticsText += "\nContext received: " + context.Name + "; type: " + context.Type);
+ }).ConfigureAwait(false);
+ textBuilder.AppendLine("Subscription is done.");
+ });
+ }
+ catch (Exception ex)
+ {
+ textBuilder.AppendLine($"AddContextListener failed: {ex.Message}, {ex}");
+ }
+ finally
+ {
+ await Dispatcher.InvokeAsync(() => DiagnosticsText = textBuilder.ToString());
+ }
+ }
+
+ private async void BroadcastButton_Click(object sender, RoutedEventArgs e)
+ {
+ var textBuilder = new StringBuilder();
+ DiagnosticsText = string.Empty;
+
+ try
+ {
await Task.Run(async () =>
{
- if (await _desktopAgent.GetCurrentChannel() == null)
+ textBuilder.AppendLine("Broadcasting is in working progress");
+ await JoinToUserChannel().ConfigureAwait(false);
+
+ var instrument = new Instrument(new InstrumentID() { BBG = "test" }, $"{Guid.NewGuid()}");
+ await _desktopAgent.Broadcast(instrument).ConfigureAwait(false);
+
+ textBuilder.AppendLine("Context broadcasted");
+ });
+ }
+ catch (Exception ex)
+ {
+ textBuilder.AppendLine($"Broadcast failed: {ex.Message}");
+ }
+ finally
+ {
+ await Dispatcher.InvokeAsync(() => DiagnosticsText = textBuilder.ToString());
+ }
+ }
+
+ private async void AppChannelBroadcastButton_Click(object sender, RoutedEventArgs e)
+ {
+ await Task.Run(async () =>
+ {
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += "\nChecking if app is already joined to an app channel...";
+ });
+
+ await JoinToAppChannel().ConfigureAwait(false);
+
+ var instrument = new Instrument(new InstrumentID() { BBG = "app-channel-test" }, $"{Guid.NewGuid().ToString()}");
+ await _appChannel.Broadcast(instrument).ConfigureAwait(false);
+
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\nContext broadcasted to AppChannel: instrument: {instrument.ID}; {instrument.Name}";
+ });
+ });
+ }
+
+ private async void AppChannelAddContextListenerButton_Click(object sender, RoutedEventArgs e)
+ {
+ await Task.Run(async () =>
+ {
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += "\nChecking if app is already joined to an app channel...";
+ });
+
+ await JoinToAppChannel().ConfigureAwait(false);
+
+ var instrument = new Instrument(new InstrumentID() { BBG = "app-channel-test" }, $"{Guid.NewGuid().ToString()}");
+ _listener = await _appChannel.AddContextListener("fdc3.instrument", (context, contextMetadata) =>
+ {
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += "\nContext received from AppChannel: " + context.Name + "; type: " + context.Type;
+ });
+ }).ConfigureAwait(false);
+
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\nContext listener is added to AppChannel: instrument: {instrument.ID}; {instrument.Name}";
+ });
+ });
+ }
+
+ private async void FindIntentButton_Click(object sender, RoutedEventArgs e)
+ {
+ await Task.Run(async () =>
+ {
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += "\nFinding intent for ViewChart...";
+ });
+
+ var result = await _desktopAgent.FindIntent("ViewChart").ConfigureAwait(false);
+
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nFindIntent is completed. Intent name: {result.Intent.Name}");
+
+ foreach (var app in result.Apps)
+ {
+ Dispatcher.Invoke(() =>
{
- await _desktopAgent.JoinUserChannel("fdc3.channel.1");
+ DiagnosticsText += $"\nIntent: {result.Intent.Name} is found for app: {app.AppId}";
+ });
+ }
+ });
+ }
+
+ private async void FindInstancesButton_Click(object sender, RoutedEventArgs e)
+ {
+ await Task.Run(async () =>
+ {
+ var result = await _desktopAgent.GetAppMetadata(new AppIdentifier() { AppId = "WPFExample" }).ConfigureAwait(false);
+
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\nFinding instances for {result.AppId}...";
+ });
+
+ var instances = await _desktopAgent.FindInstances(result).ConfigureAwait(false);
+
+ foreach (var app in instances)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\nInstance found: app: {app.AppId}; FDC3 instanceId: {app.InstanceId}";
+ });
+ }
+ });
+ }
+
+ private async void FindIntentsByContextButton_Click(object sender, RoutedEventArgs e)
+ {
+ await Task.Run(async () =>
+ {
+ var context = new Instrument();
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\nFinding intents by context: {context.Type}...";
+ });
+
+ var appIntents = await _desktopAgent.FindIntentsByContext(context).ConfigureAwait(false);
+
+ foreach (var appIntent in appIntents)
+ {
+ foreach (var app in appIntent.Apps)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\nIntent found: {appIntent.Intent.Name} for app: {app.AppId}";
+ });
}
+ }
+ });
+ }
+ private async void RaiseIntentForContextButton_Click(object sender, RoutedEventArgs e)
+ {
+ await Task.Run(async () =>
+ {
+ var context = new Nothing();
+
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\nRaising an intent for context: {context.Type}...";
+ });
+
+ var intentResolution = await _desktopAgent.RaiseIntentForContext(context).ConfigureAwait(false);
+
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\nRaiseIntentForContext is completed. Intent name: {intentResolution.Intent} for app: {intentResolution.Source.AppId}. Awaiting for IntentResolution...";
+ });
+
+ var intentResult = await intentResolution.GetResult().ConfigureAwait(false);
+
+ if (intentResult != null
+ && intentResult is IChannel channel)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\nIntentResolution is completed. Channel returned: {channel.Id}...";
+ });
+
+ if (channel is IPrivateChannel privateChannel)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\n It is a private channel with id: {privateChannel.Id}...";
+ });
+
+ _privateChannel = privateChannel;
+ _privateChannel.OnAddContextListener((ctx) =>
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nContext listener was added in private channel: {privateChannel.Id} for context: {ctx}...");
+ });
- _subscription = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) =>
+ _privateChannel.OnDisconnect(() =>
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nDisconnected from private channel: {_privateChannel.Id}...");
+ });
+
+ _privateChannel.OnUnsubscribe((ctx) =>
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nUnsubscribed from private channel: {_privateChannel.Id} for context: {ctx}...");
+ });
+ }
+ }
+ else if (intentResult != null
+ && intentResult is IContext returnedContext)
+ {
+ Dispatcher.Invoke(() =>
{
- Dispatcher.Invoke(() => DiagnosticsText += "\n" + "Context received: " + context.Name + "; type: " + context.Type);
+ DiagnosticsText += $"\nIntentResult is completed. Context returned: {returnedContext.Type}...";
});
+ }
+ else
+ {
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\nIntentResult is completed. It was handled by the app ...";
+ });
+ }
+ });
+ }
+
+ private async void AddIntentListenerButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ await Task.Run(async () =>
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += "\nAdding intent listener for OpenDiagnostics...");
+
+ _listener = await _desktopAgent.AddIntentListener("OpenDiagnostics", async (context, contextMetadata) =>
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += "\n" + "Intent received: " + context.Name + "; type: " + context.Type);
+ await CreatePrivateChannelAsync();
+
+ return _privateChannel;
+ }).ConfigureAwait(false);
});
+ }
+ catch (Exception exception)
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nException was thrown: {exception.ToString()}");
+ }
+ }
+
+ private async Task CreatePrivateChannelAsync()
+ {
+ _privateChannel = await _desktopAgent.CreatePrivateChannel().ConfigureAwait(false);
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nCreated private channel with id: {_privateChannel.Id}...");
+
+ _privateChannel.OnAddContextListener((ctx) =>
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nContextListener added in private channel: {_privateChannel.Id} for context: {ctx}...");
+ });
+
+ _privateChannel.OnDisconnect(() =>
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nDisconnected from private channel: {_privateChannel.Id}...");
+ });
+
+ _privateChannel.OnUnsubscribe((ctx) =>
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nUnsubscribed from private channel: {_privateChannel.Id} for context: {ctx}...");
+ });
+ }
- DiagnosticsText += "\n" + "Subscription is done.";
+ private async void RaiseIntentButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ await Task.Run(async () =>
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += "\nRaising intent for fdc3.instrument...");
+
+ var resolution = await _desktopAgent.RaiseIntent("OpenDiagnostics", new Instrument(new InstrumentID() { BBG = "raise-intent-test" }, "Raise Intent Test")).ConfigureAwait(false);
+
+ var result = await resolution.GetResult().ConfigureAwait(false);
+
+ if (result == null)
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += "\nIntent was handled by the app, no result returned.");
+ return;
+ }
+ else if (result is IChannel channel)
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nIntentResolution is completed. Channel returned: {channel.Id}...");
+
+ if (channel is IPrivateChannel privateChannel)
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\n It is a private channel with id: {privateChannel.Id}...");
+ }
+ }
+ else if (result is IContext context)
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\n IntentResolution is completed. Context returned: {context.Type}...");
+ }
+ });
}
- catch (Exception ex)
+ catch (Exception exception)
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nRaiseIntent failed: {exception.ToString()}");
+ }
+ }
+
+ private async void OpenButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
{
- await Dispatcher.InvokeAsync(() =>
+ await Task.Run(async () =>
{
- DiagnosticsText += $"\nAddContextListener failed: {ex.Message}, {ex.ToString()}";
+ Dispatcher.Invoke(() => DiagnosticsText += "\nOpening app with fdc3.instrument...");
+
+ var appIdentifier = await _desktopAgent.Open(new AppIdentifier() { AppId = "WPFExample" }, new Instrument(new InstrumentID() { BBG = "open-test" }, "Open Test")).ConfigureAwait(false);
+
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nOpen is completed. AppId: {appIdentifier.AppId}, instanceId: {appIdentifier.InstanceId}...");
});
}
+ catch (Exception exception)
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nOpen failed: {exception.ToString()}");
+ }
}
- private async void BroadcastButton_Click(object sender, RoutedEventArgs e)
+ private async void PrivateChannelBroadcastButton_Click(object sender, RoutedEventArgs e)
{
try
{
- Dispatcher.Invoke(() =>
+ await Task.Run(async () =>
{
- DiagnosticsText += "\nBroadcasting is in working progress";
+ if (_privateChannel == null)
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += "\nNo private channel to broadcast to. You should RaiseIntentForContext first and the new app should add its context listener to the private channel!");
+ return;
+ }
+
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\nBroadcasting to a private channel: {_privateChannel?.Id}";
+ });
+
+ var instrument = new Instrument(new InstrumentID() { BBG = "private-channel-test" }, $"{Guid.NewGuid().ToString()}");
+ await _privateChannel.Broadcast(instrument).ConfigureAwait(false);
});
+ }
+ catch (Exception exception)
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nPrivate channel broadcast failed: {exception.ToString()}");
+ }
+ }
+ private async void PrivateChannelAddContextListenerButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
await Task.Run(async () =>
{
- if (await _desktopAgent.GetCurrentChannel() == null)
+ if (_privateChannel == null)
{
- await _desktopAgent.JoinUserChannel("fdc3.channel.1");
+ Dispatcher.Invoke(() => DiagnosticsText += "\nNo private channel to broadcast to. You should RaiseIntentForContext first and the new app should add its context listener to the private channel!");
+ return;
}
- var instrument = new Instrument(new InstrumentID() { BBG = "test" }, $"{Guid.NewGuid().ToString()}");
- await _desktopAgent.Broadcast(instrument);
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\nAdding context Listener to a private channel: {_privateChannel?.Id}";
+ });
+
+ _privateChannelContextListener = await _privateChannel.AddContextListener("fdc3.instrument", (context, contextMetadata) =>
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nContext received from private channel for Context: {context}...");
+ }).ConfigureAwait(false);
});
+ }
+ catch (Exception exception)
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += $"\nPrivate channel broadcast failed: {exception.ToString()}");
+ }
+ }
+
+ private async void PrivateChannelDisconnectButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ await Task.Run(() =>
+ {
+ if (_privateChannel == null)
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += "\nNo private channel to disconnect from. You should RaiseIntentForContext first and the new app should add its context listener to the private channel!");
+ return;
+ }
- Dispatcher.Invoke(() => DiagnosticsText += "\nContext broadcasted");
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\nDisconnecting from a private channel: {_privateChannel?.Id}";
+ });
+
+ _privateChannel.Disconnect();
+ });
}
- catch (Exception ex)
+ catch (Exception exception)
{
- await Dispatcher.InvokeAsync(() =>
+ Dispatcher.Invoke(() => $"\nException was thrown: {exception.ToString()}");
+ }
+ }
+
+ private async void PrivateChannelUnsubscribeButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ await Task.Run(async () =>
+ {
+ if (_privateChannel == null)
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += "\nNo private channel to unsubscribe. You should RaiseIntentForContext first and the new app should add its context listener to the private channel!");
+ return;
+ }
+
+ if (_privateChannelContextListener == null)
+ {
+ Dispatcher.Invoke(() => DiagnosticsText += "\nNo context listener is registered on the private channel to unsubscribe. You should RaiseIntentForContext first and the new app should add its context listener to the private channel!");
+ return;
+ }
+
+ Dispatcher.Invoke(() =>
+ {
+ DiagnosticsText += $"\nUnsubscribing from a private channel: {_privateChannel?.Id} with private channel context listener";
+ });
+
+ _privateChannelContextListener.Unsubscribe();
+ });
+ }
+ catch (Exception exception)
+ {
+ Dispatcher.Invoke(() => $"\nException was thrown: {exception.ToString()}");
+ }
+ }
+
+ private async Task JoinToAppChannel()
+ {
+ if (_appChannel == null)
+ {
+ _appChannel = await _desktopAgent.GetOrCreateChannel("app-channel-1").ConfigureAwait(false);
+
+ Dispatcher.Invoke(() =>
{
- DiagnosticsText += $"\nBroadcast failed: {ex.Message}";
+ DiagnosticsText += "\nJoined to AppChannel: app-channel-1...";
});
}
}
+
+ private async Task JoinToUserChannel()
+ {
+ if (await _desktopAgent.GetCurrentChannel() == null)
+ {
+ var channels = await _desktopAgent.GetUserChannels();
+ var firstChannel = channels.FirstOrDefault();
+ if (firstChannel == null)
+ {
+ await _desktopAgent.JoinUserChannel("fdc3.channel.1");
+ return;
+ }
+
+ await _desktopAgent.JoinUserChannel(firstChannel.Id);
+ }
+ }
}
diff --git a/examples/fdc3-appdirectory/apps-with-intents.json b/examples/fdc3-appdirectory/apps-with-intents.json
index 68696c0c8..8b15b3992 100644
--- a/examples/fdc3-appdirectory/apps-with-intents.json
+++ b/examples/fdc3-appdirectory/apps-with-intents.json
@@ -33,6 +33,14 @@
"interop": {
"intents": {
"listensFor": {
+ "OpenDiagnostics": {
+ "name": "OpenDiagnostics",
+ "displayName": "OpenDiagnostics",
+ "contexts": [
+ "fdc3.nothing",
+ "fdc3.instrument"
+ ]
+ }
}
}
}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.sln b/src/fdc3/dotnet/DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.sln
index 20a8c9dc0..03979a8bc 100644
--- a/src/fdc3/dotnet/DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.sln
+++ b/src/fdc3/dotnet/DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.sln
@@ -47,7 +47,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MorganStanley.ComposeUI.Uti
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TestHelpers", "TestHelpers", "{3E857ED6-80F3-41EA-A1A9-B74EB5D3A92C}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DesktopAgentClientConsoleApp", "test\TestHelpers\DesktopAgentClientConsoleApp\DesktopAgentClientConsoleApp.csproj", "{C5B762AB-D9CA-45FE-A683-B190BC927CA9}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DesktopAgentClientConsoleApp", "test\TestHelpers\DesktopAgentClientConsoleApp\DesktopAgentClientConsoleApp.csproj", "{C5B762AB-D9CA-45FE-A683-B190BC927CA9}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.IntegrationTests", "test\IntegrationTests\MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.IntegrationTests.csproj", "{77033EEF-BEFD-42D2-A266-F6B8F508E758}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -115,6 +117,10 @@ Global
{C5B762AB-D9CA-45FE-A683-B190BC927CA9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C5B762AB-D9CA-45FE-A683-B190BC927CA9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C5B762AB-D9CA-45FE-A683-B190BC927CA9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {77033EEF-BEFD-42D2-A266-F6B8F508E758}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {77033EEF-BEFD-42D2-A266-F6B8F508E758}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {77033EEF-BEFD-42D2-A266-F6B8F508E758}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {77033EEF-BEFD-42D2-A266-F6B8F508E758}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -136,6 +142,7 @@ Global
{87D74A9A-1FA7-40D5-BA5E-0AB195C4CDF9} = {81BDD623-4A89-48B0-91F2-731AB96BAF7B}
{3E857ED6-80F3-41EA-A1A9-B74EB5D3A92C} = {4BC4D2DE-3E72-4912-B853-F19BB0D5F732}
{C5B762AB-D9CA-45FE-A683-B190BC927CA9} = {3E857ED6-80F3-41EA-A1A9-B74EB5D3A92C}
+ {77033EEF-BEFD-42D2-A266-F6B8F508E758} = {4BC4D2DE-3E72-4912-B853-F19BB0D5F732}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C50D6270-78F7-4C27-9DCD-82D041C849D1}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/Directory.Build.props b/src/fdc3/dotnet/DesktopAgent.Client/src/Directory.Build.props
index 67729cbb0..8c40b554c 100644
--- a/src/fdc3/dotnet/DesktopAgent.Client/src/Directory.Build.props
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/Directory.Build.props
@@ -1,12 +1,13 @@
-
- false
+ true
-
+
-
+
+
+
\ No newline at end of file
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/DependencyInjection/ServiceCollectionExtensions.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/DependencyInjection/ServiceCollectionExtensions.cs
index 8dc20b417..9db7cbeea 100644
--- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/DependencyInjection/ServiceCollectionExtensions.cs
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/DependencyInjection/ServiceCollectionExtensions.cs
@@ -29,7 +29,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddFdc3DesktopAgentClient(
this IServiceCollection serviceCollection)
{
- serviceCollection.AddSingleton();
+ serviceCollection.AddTransient();
return serviceCollection;
}
}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/DesktopAgentClient.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/DesktopAgentClient.cs
index fd9edf818..2f4f09f98 100644
--- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/DesktopAgentClient.cs
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/DesktopAgentClient.cs
@@ -18,11 +18,15 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared;
using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions;
using MorganStanley.ComposeUI.Messaging.Abstractions;
namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure;
+///
+/// Desktop Agent client implementation.
+///
public class DesktopAgentClient : IDesktopAgent
{
private readonly IMessaging _messaging;
@@ -30,16 +34,26 @@ public class DesktopAgentClient : IDesktopAgent
private readonly ILogger _logger;
private readonly string _appId;
private readonly string _instanceId;
- private readonly IChannelFactory _channelFactory;
- private readonly IMetadataClient _metadataClient;
+ private IChannelFactory _channelFactory;
+ private IMetadataClient _metadataClient;
+ private IIntentsClient _intentsClient;
+ private IOpenClient _openClient;
//This cache stores the top-level context listeners added through the `AddContextListener(...)` API. It stores their actions to be able to resubscribe them when joining a new channel and handle the last context based on the FDC3 standard.
private readonly Dictionary> _contextListeners = new();
+ private readonly ConcurrentDictionary _intentListeners = new();
private IChannel? _currentChannel;
+ private IContext? _openedAppContext;
+
private readonly SemaphoreSlim _currentChannelLock = new(1, 1);
- private readonly Dictionary _userChannels = new();
+ private readonly ConcurrentDictionary _userChannels = new();
+ private readonly ConcurrentDictionary _appChannels = new();
+ private readonly SemaphoreSlim _appChannelsLock = new(1, 1);
+
+ private readonly TaskCompletionSource _initializationTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private string? _openedAppContextId;
public DesktopAgentClient(
IMessaging messaging,
@@ -52,19 +66,83 @@ public DesktopAgentClient(
_appId = Environment.GetEnvironmentVariable(nameof(AppIdentifier.AppId)) ?? throw ThrowHelper.MissingAppId(string.Empty);
_instanceId = Environment.GetEnvironmentVariable(nameof(AppIdentifier.InstanceId)) ?? throw ThrowHelper.MissingInstanceId(_appId, string.Empty);
- _channelFactory = new ChannelFactory(_messaging, _instanceId, _loggerFactory);
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("AppID: {AppId}; InstanceId: {InstanceId} is registered for the FDC3 client app.", _appId, _instanceId);
+ }
+
_metadataClient = new MetadataClient(_appId, _instanceId, _messaging, _loggerFactory.CreateLogger());
+ _openClient = new OpenClient(_instanceId, _messaging, this, _loggerFactory.CreateLogger());
+
+ _ = Task.Run(() => InitializeAsync());
+ }
+
+ ///
+ /// Joins to a user channel if the channel id is initially defined for the app and requests to backend to return the context if the app was opened through fdc3.open call.
+ ///
+ ///
+ private async Task InitializeAsync()
+ {
+ try
+ {
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Initializing DesktopAgentClient...");
+ }
+
+ var channelId = Environment.GetEnvironmentVariable(Fdc3StartupParameters.Fdc3ChannelId);
+ var openedAppContextId = Environment.GetEnvironmentVariable(Fdc3StartupParameters.OpenedAppContextId);
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Retrieved startup parameters for channelId: {ChannelId}; openedAppContextId: {OpenedAppContext}.", channelId ?? "null", openedAppContextId ?? "null");
+ }
+
+ if (!string.IsNullOrEmpty(channelId))
+ {
+ await JoinUserChannel(channelId!).ConfigureAwait(false);
+ }
+
+ if (!string.IsNullOrEmpty(openedAppContextId))
+ {
+ _openedAppContextId = openedAppContextId;
+ await GetOpenedAppContextAsync(openedAppContextId!).ConfigureAwait(false);
+ }
+
+ _channelFactory = new ChannelFactory(_messaging, _instanceId, _openedAppContext, _loggerFactory);
+ _intentsClient = new IntentsClient(_messaging, _channelFactory, _instanceId, _loggerFactory);
+
+ _initializationTaskCompletionSource.SetResult(_instanceId);
+ }
+ catch (Exception exception)
+ {
+ _initializationTaskCompletionSource.TrySetException(exception);
+ }
}
- //TODO: AddContextListener should be revisited when the Open is being implemented as the first context that should be handled is the context which is passed through the fdc3.open call.
+ ///
+ /// Adds a context listener for the specified context type.
+ ///
+ ///
+ ///
+ ///
+ ///
public async Task AddContextListener(string? contextType, ContextHandler handler) where T : IContext
{
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
ContextListener listener = null;
+ await _currentChannelLock.WaitAsync().ConfigureAwait(false);
+
try
{
- await _currentChannelLock.WaitAsync().ConfigureAwait(false);
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug(
+ "Checking if the app was opened via fdc3.open. OpenedAppContext exists: {IsNotNull}, current context listener's context type: {ContextType}, received app context's type via open call: {OpenedAppContextType}.", _openedAppContext != null, contextType, _openedAppContext?.Type);
+ }
- listener = await _channelFactory.CreateContextListener(handler, _currentChannel, contextType);
+ listener = await _channelFactory.CreateContextListenerAsync(handler, _currentChannel, contextType);
_contextListeners.Add(
listener,
@@ -74,11 +152,6 @@ public async Task AddContextListener(string? contextType, ContextH
await HandleLastContextAsync(listener);
});
- if (_logger.IsEnabled(LogLevel.Debug))
- {
- _logger.LogDebug("Context listener added for context type: {ContextType}.", contextType ?? "null");
- }
-
return listener;
}
finally
@@ -89,13 +162,44 @@ public async Task AddContextListener(string? contextType, ContextH
}
}
- public Task AddIntentListener(string intent, IntentHandler handler) where T : IContext
+ ///
+ /// Adds an intent listener for the specified intent.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task AddIntentListener(string intent, IntentHandler handler) where T : IContext
{
- throw new NotImplementedException();
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ if (_intentListeners.TryGetValue(intent, out var existingListener))
+ {
+ return existingListener;
+ }
+
+ var listener = await _intentsClient.AddIntentListenerAsync(intent, handler);
+
+ if (!_intentListeners.TryAdd(intent, listener))
+ {
+ if (_logger.IsEnabled(LogLevel.Warning))
+ {
+ _logger.LogWarning("Failed to add intent listener to the internal collection: {Intent}.", intent);
+ }
+ }
+
+ return listener;
}
+ ///
+ /// Broadcasts the specified context to the current channel.
+ ///
+ ///
+ ///
public async Task Broadcast(IContext context)
{
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
try
{
await _currentChannelLock.WaitAsync().ConfigureAwait(false);
@@ -113,55 +217,152 @@ public async Task Broadcast(IContext context)
}
}
- public Task CreatePrivateChannel()
+ ///
+ /// Creates a new private channel.
+ ///
+ ///
+ public async Task CreatePrivateChannel()
{
- throw new NotImplementedException();
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+ var privateChannel = await _channelFactory.CreatePrivateChannelAsync();
+
+ return privateChannel;
}
- public Task> FindInstances(IAppIdentifier app)
+ ///
+ /// Lists all instances of the specified app.
+ ///
+ ///
+ ///
+ public async Task> FindInstances(IAppIdentifier app)
{
- throw new NotImplementedException();
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ var instances = await _metadataClient.FindInstancesAsync(app);
+ return instances;
}
- public Task FindIntent(string intent, IContext? context = null, string? resultType = null)
+ ///
+ /// Finds an app intent matching the specified parameters from the AppDirectory.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task FindIntent(string intent, IContext? context = null, string? resultType = null)
{
- throw new NotImplementedException();
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ var result = await _intentsClient.FindIntentAsync(intent, context, resultType);
+ return result;
}
- public Task> FindIntentsByContext(IContext context, string? resultType = null)
+ ///
+ /// Lists all app intents matching the specified context and other parameters from the AppDirectory.
+ ///
+ ///
+ ///
+ ///
+ public async Task> FindIntentsByContext(IContext context, string? resultType = null)
{
- throw new NotImplementedException();
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ var appIntents = await _intentsClient.FindIntentsByContextAsync(context, resultType);
+ return appIntents;
}
+ ///
+ /// Retrieves metadata for the specified app.
+ ///
+ ///
+ ///
public async Task GetAppMetadata(IAppIdentifier app)
{
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
var appMetadata = await _metadataClient.GetAppMetadataAsync(app);
return appMetadata;
}
- public Task GetCurrentChannel()
+ ///
+ /// Retrieves the current user channel the app is connected to.
+ ///
+ ///
+ public async Task GetCurrentChannel()
{
- return Task.FromResult(_currentChannel);
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ return _currentChannel;
}
+ ///
+ /// Retrieves implementation metadata of the current application.
+ ///
+ ///
public async Task GetInfo()
{
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
var implementationMetadata = await _metadataClient.GetInfoAsync();
return implementationMetadata;
}
- public Task GetOrCreateChannel(string channelId)
+ ///
+ /// Creates or retrieves an application channel with the specified id.
+ ///
+ ///
+ ///
+ public async Task GetOrCreateChannel(string channelId)
{
- throw new NotImplementedException();
+ try
+ {
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+ await _appChannelsLock.WaitAsync().ConfigureAwait(false);
+
+ if (_appChannels.TryGetValue(channelId, out var existingChannel))
+ {
+ return existingChannel;
+ }
+
+ var channel = await _channelFactory.CreateAppChannelAsync(channelId);
+
+ if (!_appChannels.TryAdd(channelId, channel))
+ {
+ if (_logger.IsEnabled(LogLevel.Warning))
+ {
+ _logger.LogWarning("Failed to add app channel to the internal collection: {ChannelId}.", channelId);
+ }
+ }
+
+ return channel;
+ }
+ finally
+ {
+ _appChannelsLock.Release();
+ }
}
- public Task> GetUserChannels()
+ ///
+ /// Lists all user channels available.
+ ///
+ ///
+ public async Task> GetUserChannels()
{
- throw new NotImplementedException();
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ var channels = await _channelFactory.GetUserChannelsAsync();
+ return channels;
}
+ ///
+ /// Joins to a user channel with the specified id.
+ ///
+ ///
+ ///
public async Task JoinUserChannel(string channelId)
{
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
try
{
await _currentChannelLock.WaitAsync().ConfigureAwait(false);
@@ -197,8 +398,14 @@ public async Task JoinUserChannel(string channelId)
}
}
+ ///
+ /// Leaves the current user channel.
+ ///
+ ///
public async Task LeaveCurrentChannel()
{
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
try
{
await _currentChannelLock.WaitAsync().ConfigureAwait(false);
@@ -223,21 +430,75 @@ public async Task LeaveCurrentChannel()
}
}
- public Task Open(IAppIdentifier app, IContext? context = null)
+ ///
+ /// Opens the specified app.
+ ///
+ ///
+ ///
+ ///
+ public async Task Open(IAppIdentifier app, IContext? context = null)
+ {
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ var appIdentifier = await _openClient.OpenAsync(app, context);
+ return appIdentifier;
+ }
+
+ ///
+ /// Raises the specified intent with the given context and optionally targets specific app to handle it. When multiple apps can handle the intent, the user will be prompted to choose one.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task RaiseIntent(string intent, IContext context, IAppIdentifier? app = null)
{
- throw new NotImplementedException();
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ var intentResolution = await _intentsClient.RaiseIntentAsync(intent, context, app);
+ return intentResolution;
}
- public Task RaiseIntent(string intent, IContext context, IAppIdentifier? app = null)
+ ///
+ /// Raises an intent for the given context and optionally targets specific app to handle it. When multiple apps can handle the intent, the user will be prompted to choose one.
+ ///
+ ///
+ ///
+ ///
+ public async Task RaiseIntentForContext(IContext context, IAppIdentifier? app = null)
{
- throw new NotImplementedException();
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ var intentResolution = await _intentsClient.RaiseIntentForContextAsync(context, app);
+ return intentResolution;
}
- public Task RaiseIntentForContext(IContext context, IAppIdentifier? app = null)
+ ///
+ /// Checks if the app was opened through fdc3.open and retrieves the context associated with the opened app.
+ ///
+ ///
+ ///
+ internal async ValueTask GetOpenedAppContextAsync(string openedAppContextId)
{
- throw new NotImplementedException();
+ try
+ {
+ _openedAppContext = await _openClient.GetOpenAppContextAsync(openedAppContextId);
+ }
+ catch (Fdc3DesktopAgentException exception)
+ {
+ if (_logger.IsEnabled(LogLevel.Error))
+ {
+ _logger.LogError(exception, "No context was received for the opened app.");
+ }
+ }
}
+ ///
+ /// Handles the last context in the current channel for the specified context listener.
+ ///
+ ///
+ ///
+ ///
private async Task HandleLastContextAsync(
ContextListener? listener = null)
where T : IContext
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelFactory.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelFactory.cs
index fd962849f..eab12afd2 100644
--- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelFactory.cs
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelFactory.cs
@@ -31,7 +31,7 @@ internal interface IChannelFactory
/// The channel to listen on. If null, the default channel is used.
/// The context type to filter for. If null, all context types are received.
/// A representing the asynchronous operation.
- public ValueTask> CreateContextListener(
+ public ValueTask> CreateContextListenerAsync(
ContextHandler contextHandler,
IChannel? currentChannel = null,
string? contextType = null)
@@ -43,4 +43,31 @@ public ValueTask> CreateContextListener(
/// The ID of the user channel to join.
/// A representing the asynchronous operation.
public ValueTask JoinUserChannelAsync(string channelId);
+
+ ///
+ /// Retrieves all available user channels.
+ ///
+ /// A representing the asynchronous operation that returns an array of user channels.
+ public ValueTask> GetUserChannelsAsync();
+
+ ///
+ /// Gets or creates an application channel with the specified channel ID.
+ ///
+ ///
+ ///
+ public ValueTask CreateAppChannelAsync(string channelId);
+
+ ///
+ /// Sends a request to the backend to check if the channel with the specified ID and type exists, and if so, returns it.
+ ///
+ ///
+ ///
+ ///
+ public ValueTask FindChannelAsync(string channelId, ChannelType channelType);
+
+ ///
+ /// Creates a private channel by sending request to the backend.
+ ///
+ ///
+ public ValueTask CreatePrivateChannelAsync();
}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IIntentsClient.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IIntentsClient.cs
new file mode 100644
index 000000000..aa395dea7
--- /dev/null
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IIntentsClient.cs
@@ -0,0 +1,71 @@
+/*
+ * 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 Finos.Fdc3;
+using Finos.Fdc3.Context;
+
+namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure;
+
+///
+/// Provides methods for interacting with FDC3 intents in the desktop agent client infrastructure.
+///
+internal interface IIntentsClient
+{
+ ///
+ /// Finds an intent by name, optionally filtering by context and result type by sending the request to the backend.
+ ///
+ /// The name of the intent to find.
+ /// Optional context(type) to filter the intent search.
+ /// Optional result type to filter the intent search.
+ ///
+ /// A representing the asynchronous operation to find the intent.
+ ///
+ public ValueTask FindIntentAsync(string intent, IContext? context = null, string? resultType = null);
+
+ ///
+ /// Finds all intents that can handle the specified context, optionally filtering by result type by sending the request to the backend.
+ ///
+ ///
+ ///
+ ///
+ public ValueTask> FindIntentsByContextAsync(IContext context, string? resultType = null);
+
+ ///
+ /// Raises an intent for a given context, optionally targeting a specific application. If multiple applications can handle the request the container should allow the user to choose the right app.
+ ///
+ ///
+ ///
+ ///
+ public ValueTask RaiseIntentForContextAsync(IContext context, IAppIdentifier? app);
+
+ ///
+ /// Adds an intent listener which handles the sent intents with the provided handler.
+ /// This registers the listener on the backend and returns an to manage the listener's lifecycle.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public ValueTask AddIntentListenerAsync(string intent, IntentHandler handler)
+ where T : IContext;
+
+ ///
+ /// Raises an intent, optionally targeting a specific application. If multiple applications can handle the request the container should allow the user to choose the right app.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public ValueTask RaiseIntentAsync(string intent, IContext context, IAppIdentifier? app);
+}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IMetadataClient.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IMetadataClient.cs
index 73c550d7e..3b2640d2f 100644
--- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IMetadataClient.cs
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IMetadataClient.cs
@@ -37,4 +37,11 @@ internal interface IMetadataClient
/// A representing the asynchronous operation to retrieve application metadata.
///
public ValueTask GetAppMetadataAsync(IAppIdentifier appIdentifier);
+
+ ///
+ /// Finds all instances of a specific FDC3 application.
+ ///
+ ///
+ ///
+ public ValueTask> FindInstancesAsync(IAppIdentifier appIdentifier);
}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IOpenClient.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IOpenClient.cs
new file mode 100644
index 000000000..85cd81546
--- /dev/null
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IOpenClient.cs
@@ -0,0 +1,42 @@
+/*
+ * 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 Finos.Fdc3;
+using Finos.Fdc3.Context;
+
+namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure;
+
+///
+/// Provides functionality to open an application with an optional FDC3 context.
+///
+internal interface IOpenClient
+{
+ ///
+ /// Opens the specified application, optionally passing an FDC3 context.
+ ///
+ /// The application identifier to open.
+ /// The optional FDC3 context to pass to the application.
+ ///
+ /// A representing the asynchronous operation,
+ /// with the identifier of the opened application as the result.
+ ///
+ public ValueTask OpenAsync(IAppIdentifier app, IContext? context);
+
+ ///
+ /// Retrieves the sent context from the initiator if it was opened via the fdc3.open call.
+ ///
+ /// The context ID associated with the open app request.
+ ///
+ public ValueTask GetOpenAppContextAsync(string openAppContextId);
+}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelFactory.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelFactory.cs
index 1c541d5a0..a60eafb3b 100644
--- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelFactory.cs
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelFactory.cs
@@ -12,11 +12,13 @@
* and limitations under the License.
*/
+using System.Collections.Concurrent;
using System.Text.Json;
using Finos.Fdc3;
using Finos.Fdc3.Context;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal.Protocol;
using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared;
using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts;
using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions;
@@ -29,22 +31,26 @@ internal class ChannelFactory : IChannelFactory
{
private readonly IMessaging _messaging;
private readonly string _instanceId;
+ private readonly IContext? _openedAppContext;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization;
+ private readonly ConcurrentDictionary _privateChannels = new();
public ChannelFactory(
IMessaging messaging,
string fdc3InstanceId,
+ IContext? openedAppContext = null,
ILoggerFactory? loggerFactory = null)
{
_messaging = messaging;
_instanceId = fdc3InstanceId;
+ _openedAppContext = openedAppContext;
_loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
_logger = _loggerFactory.CreateLogger();
}
- public async ValueTask> CreateContextListener(
+ public async ValueTask> CreateContextListenerAsync(
ContextHandler contextHandler,
IChannel? currentChannel = null,
string? contextType = null)
@@ -57,6 +63,7 @@ public async ValueTask> CreateContextListener(
contextHandler: contextHandler,
messaging: _messaging,
contextType: contextType,
+ openedAppContext: _openedAppContext,
logger: _loggerFactory.CreateLogger>());
}
@@ -72,6 +79,99 @@ public async ValueTask> CreateContextListener(
}
}
+ public async ValueTask CreateAppChannelAsync(string channelId)
+ {
+ var request = new CreateAppChannelRequest
+ {
+ InstanceId = _instanceId,
+ ChannelId = channelId,
+ };
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.CreateAppChannel,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (!response.Success)
+ {
+ throw ThrowHelper.ErrorResponseReceived(Fdc3DesktopAgentErrors.UnspecifiedReason);
+ }
+
+ return new Channel(
+ channelId: channelId,
+ channelType: ChannelType.App,
+ messaging: _messaging,
+ instanceId: _instanceId,
+ displayMetadata: null,
+ loggerFactory: _loggerFactory);
+ }
+
+ public async ValueTask> GetUserChannelsAsync()
+ {
+ var request = new GetUserChannelsRequest
+ {
+ InstanceId = _instanceId
+ };
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.GetUserChannels,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (response.Channels == null || !response.Channels.Any())
+ {
+ throw ThrowHelper.DesktopAgentBackendDidNotResolveRequest(nameof(GetUserChannelsRequest), nameof(response.Channels), Fdc3DesktopAgentErrors.NoUserChannelSetFound);
+ }
+
+ var channels = new List();
+
+ foreach (var channel in response.Channels)
+ {
+ if (channel.DisplayMetadata == null)
+ {
+ _logger.LogDebug("Skipping channel with missing ChannelId: {ChannelId} or the {NameOfDisplayMetadata}.", channel.Id, nameof(DisplayMetadata));
+ }
+
+ if (string.IsNullOrEmpty(channel.Id))
+ {
+ _logger.LogDebug("Skipping channel with missing {NameOfChannelId}.", nameof(channel.Id));
+ continue;
+ }
+
+ var userChannel = new Channel(
+ channelId: channel.Id,
+ channelType: ChannelType.User,
+ instanceId: _instanceId,
+ messaging: _messaging,
+ displayMetadata: channel.DisplayMetadata,
+ loggerFactory: _loggerFactory);
+
+ channels.Add(userChannel);
+ }
+
+ return channels;
+ }
+
public async ValueTask JoinUserChannelAsync(string channelId)
{
var request = new JoinUserChannelRequest
@@ -105,9 +205,136 @@ public async ValueTask JoinUserChannelAsync(string channelId)
channelType: ChannelType.User,
instanceId: _instanceId,
messaging: _messaging,
+ openedAppContext: _openedAppContext,
displayMetadata: response.DisplayMetadata,
loggerFactory: _loggerFactory);
return channel;
}
+
+ public async ValueTask FindChannelAsync(string channelId, ChannelType channelType)
+ {
+ var request = new FindChannelRequest
+ {
+ ChannelId = channelId,
+ ChannelType = channelType
+ };
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.FindChannel,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (!response.Found)
+ {
+ throw ThrowHelper.ChannelNotFound(channelId, channelType);
+ }
+
+ if (channelType == ChannelType.Private)
+ {
+ return await JoinPrivateChannelAsync(channelId);
+ }
+
+ //This is only called when raising an intent, the RaiseIntent logic.
+ return new Channel(
+ channelId: channelId,
+ channelType: channelType,
+ instanceId: _instanceId,
+ messaging: _messaging,
+ openedAppContext: _openedAppContext,
+ loggerFactory: _loggerFactory);
+ }
+
+ public async ValueTask CreatePrivateChannelAsync()
+ {
+ var request = new CreatePrivateChannelRequest
+ {
+ InstanceId = _instanceId
+ };
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.CreatePrivateChannel,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ throw ThrowHelper.PrivateChannelCreationFailed();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ var channel = new PrivateChannel(
+ channelId: response.ChannelId!,
+ instanceId: _instanceId,
+ messaging: _messaging,
+ isOriginalCreator: true,
+ onDisconnect: () => _privateChannels.TryRemove(response.ChannelId!, out _),
+ displayMetadata: null,
+ loggerFactory: _loggerFactory);
+
+ _privateChannels.TryAdd(response.ChannelId!, channel);
+
+ return channel;
+ }
+
+ private async ValueTask JoinPrivateChannelAsync(string channelId)
+ {
+ var request = new JoinPrivateChannelRequest
+ {
+ InstanceId = _instanceId,
+ ChannelId = channelId
+ };
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.JoinPrivateChannel,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (!response.Success)
+ {
+ throw ThrowHelper.PrivateChannelJoiningFailed(channelId);
+ }
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Joined private channel {ChannelId}.", channelId);
+ }
+
+ var channel = _privateChannels.GetOrAdd(
+ channelId,
+ new PrivateChannel(
+ channelId: channelId,
+ instanceId: _instanceId,
+ messaging: _messaging,
+ isOriginalCreator: false,
+ onDisconnect: () => _privateChannels.TryRemove(channelId, out _),
+ displayMetadata: null,
+ loggerFactory: _loggerFactory));
+
+ return channel;
+ }
}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ContextListener.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ContextListener.cs
index 63a9374cf..e159b5c32 100644
--- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ContextListener.cs
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ContextListener.cs
@@ -12,6 +12,7 @@
* and limitations under the License.
*/
+using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Nodes;
using Finos.Fdc3;
@@ -32,18 +33,20 @@ internal class ContextListener : IListener, IAsyncDisposable
private readonly ContextHandler _contextHandler;
private readonly string? _contextType;
private readonly IMessaging _messaging;
+ private readonly T? _openedAppContext;
private readonly ILogger> _logger;
private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization;
-
- private readonly SemaphoreSlim _serializedContextsLock = new(1, 1);
- private readonly List _serializedContexts = new();
-
private readonly SemaphoreSlim _subscriptionLock = new(1,1);
private bool _isSubscribed = false;
- private IAsyncDisposable? _subscription;
+ private IAsyncDisposable? _subscription;
private string _contextListenerId;
+ private bool _isOpenedAppContextHandled = false;
+
+ private readonly ConcurrentQueue _contexts = new();
+ private Action>? _unsubscribeCallback;
+
public string? ContextType => _contextType;
public ContextListener(
@@ -51,12 +54,23 @@ public ContextListener(
ContextHandler contextHandler,
IMessaging messaging,
string? contextType = null,
+ IContext? openedAppContext = null,
ILogger>? logger = null)
{
_instanceId = instanceId ?? throw new ArgumentNullException(nameof(instanceId));
_contextHandler = contextHandler;
_contextType = contextType;
_messaging = messaging;
+
+ if (openedAppContext != null && openedAppContext is T typedContext)
+ {
+ _openedAppContext = typedContext;
+ }
+ else
+ {
+ _isOpenedAppContextHandled = true;
+ }
+
_logger = logger ?? NullLogger>.Instance;
}
@@ -89,6 +103,11 @@ public void Unsubscribe()
.GetResult();
_isSubscribed = false;
+
+ if (_unsubscribeCallback != null)
+ {
+ _unsubscribeCallback(this);
+ }
}
finally
{
@@ -101,7 +120,6 @@ public async ValueTask SubscribeAsync(string channelId, ChannelType channelType,
try
{
await _subscriptionLock.WaitAsync().ConfigureAwait(false);
- await _serializedContextsLock.WaitAsync().ConfigureAwait(false);
if (_isSubscribed)
{
@@ -111,6 +129,12 @@ public async ValueTask SubscribeAsync(string channelId, ChannelType channelType,
await RegisterContextListenerAsync(channelId, channelType).ConfigureAwait(false);
var topic = new ChannelTopics(channelId, channelType);
+ if (_openedAppContext != null && !_isOpenedAppContextHandled)
+ {
+ _contextHandler(_openedAppContext);
+ _isOpenedAppContextHandled = true;
+ }
+
_subscription = await _messaging.SubscribeAsync(
topic.Broadcast,
serializedContext =>
@@ -136,8 +160,13 @@ public async ValueTask SubscribeAsync(string channelId, ChannelType channelType,
_logger.LogDebug("Context received: {SerializedContext}; Type to deserialize to: {Type}...", serializedContext, typeof(T));
}
+ if (!_isOpenedAppContextHandled)
+ {
+ _contexts.Enqueue(context!);
+ return new ValueTask();
+ }
+
_contextHandler(context!);
- _serializedContexts.Add(context!);
return new ValueTask();
}, cancellationToken);
@@ -152,7 +181,6 @@ public async ValueTask SubscribeAsync(string channelId, ChannelType channelType,
finally
{
_subscriptionLock.Release();
- _serializedContextsLock.Release();
_logger.LogInformation("Context listener subscribed to channel {ChannelId} for context type {ContextType}.", channelId, _contextType);
}
@@ -181,7 +209,13 @@ public async Task HandleContextAsync(IContext context)
return;
}
- _contextHandler((T) context);
+ if (!_isOpenedAppContextHandled)
+ {
+ _contexts.Enqueue(typedContext);
+ return;
+ }
+
+ _contextHandler(typedContext);
}
finally
{
@@ -189,6 +223,26 @@ public async Task HandleContextAsync(IContext context)
}
}
+ internal Task SetOpenHandledAsync(bool handled)
+ {
+ _isOpenedAppContextHandled = handled;
+ return Task.CompletedTask;
+ }
+
+ //TODO: decide if we want to handle the cached contexts after the context via the fdc3.open call is handled
+ private Task HandleCachedContextsAsync()
+ {
+ if (_isOpenedAppContextHandled)
+ {
+ while (_contexts.TryDequeue(out var context))
+ {
+ _contextHandler(context);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
private async ValueTask RegisterContextListenerAsync(
string channelId,
ChannelType channelType)
@@ -252,4 +306,9 @@ private async ValueTask UnregisterContextListenerAsync()
_contextListenerId = null;
}
+
+ internal void SetUnsubscribeCallback(Action> unsubscribeCallback)
+ {
+ _unsubscribeCallback = unsubscribeCallback;
+ }
}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/IntentListener.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/IntentListener.cs
new file mode 100644
index 000000000..cc3046b37
--- /dev/null
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/IntentListener.cs
@@ -0,0 +1,265 @@
+/*
+ * 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;
+using Finos.Fdc3;
+using Finos.Fdc3.Context;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions;
+using MorganStanley.ComposeUI.Messaging.Abstractions;
+
+namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal;
+
+internal class IntentListener : IListener
+ where T : IContext
+{
+ private readonly IMessaging _messaging;
+ private readonly string _intent;
+ private readonly string _instanceId;
+ private readonly IntentHandler _intentHandler;
+ private readonly ILogger> _logger;
+ private readonly SemaphoreSlim _semaphore = new(1, 1);
+ private bool _isRegistered = false;
+ private IAsyncDisposable _subscription;
+ private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization;
+
+ public IntentListener(
+ IMessaging messaging,
+ string intent,
+ string instanceId,
+ IntentHandler handler,
+ ILogger>? logger = null)
+ {
+ _messaging = messaging;
+ _intent = intent;
+ _instanceId = instanceId;
+ _intentHandler = handler;
+ _logger = logger ?? NullLogger>.Instance;
+ }
+
+ public async ValueTask RegisterIntentHandlerAsync()
+ {
+ try
+ {
+ await _semaphore.WaitAsync();
+ if (_isRegistered)
+ {
+ return;
+ }
+
+ var topic = Fdc3Topic.RaiseIntentResolution(_intent, _instanceId);
+
+ _subscription = await _messaging.SubscribeJsonAsync(
+ topic,
+ HandleIntentMessageAsync,
+ _jsonSerializerOptions);
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("{NameOfIntentListener} is subscribed for {Intent} with instance id: {InstanceId} to topic: {Topic}.", nameof(IntentListener), _intent, _instanceId, topic);
+ }
+
+ _isRegistered = true;
+ }
+ finally
+ {
+ _semaphore.Release();
+ }
+ }
+
+ public void Unsubscribe()
+ {
+ try
+ {
+ _semaphore.Wait();
+
+ var request = CreateUnsubscribeRequest();
+ LogDebug("Unsubscribing intent listener for intent {Intent} and instanceId {InstanceId}...", _intent, _instanceId);
+
+ var response = _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.AddIntentListener,
+ request,
+ _jsonSerializerOptions).GetAwaiter().GetResult();
+
+ ValidateUnsubscribeResponse(response);
+
+ _subscription.DisposeAsync().GetAwaiter().GetResult();
+
+ LogDebug("Successfully unsubscribed intent listener for intent {Intent} and instanceId {InstanceId}.", _intent, _instanceId);
+ _isRegistered = false;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while unsubscribing intent listener for intent {Intent} and instanceId {InstanceId}.", _intent, _instanceId);
+ throw;
+ }
+ finally
+ {
+ _semaphore.Release();
+ }
+ }
+
+ private IntentListenerRequest CreateUnsubscribeRequest() =>
+ new IntentListenerRequest
+ {
+ Intent = _intent,
+ Fdc3InstanceId = _instanceId,
+ State = SubscribeState.Unsubscribe
+ };
+
+ private void ValidateUnsubscribeResponse(IntentListenerResponse? response)
+ {
+ if (response == null)
+ {
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (response.Stored)
+ {
+ throw ThrowHelper.ListenerNotUnRegistered(_intent, _instanceId);
+ }
+ }
+
+ private async Task SetRequestResultAsync(object? intentResult, StoreIntentResultRequest request, string messageId)
+ {
+ try
+ {
+ if (intentResult == null) //It is a simply void
+ {
+ LogDebug("The intent result is void for intent:{Intent} for message: {MessageId}.", _intent, messageId);
+ request.VoidResult = true;
+ }
+ else if (intentResult is Task resolvableTask) //It is a task with some return type
+ {
+ var resolvedIntentResult = await resolvableTask;
+
+ if (resolvedIntentResult is IChannel channel)
+ {
+ LogDebug("The intent result is a channel for intent:{Intent} for message: {MessageId}. Channel: {Channel}", _intent, messageId, Serialize(channel));
+ request.ChannelId = channel.Id;
+ request.ChannelType = channel.Type;
+ }
+ else if (resolvedIntentResult is IContext ctx)
+ {
+ var context = Serialize(ctx);
+ LogDebug("The intent result is a context for intent:{Intent} for message: {MessageId}. Context: {Context}", _intent, messageId, context);
+ request.Context = context;
+ }
+ else // it is a resolvable task with no return type
+ {
+ LogDebug("The intent result is void for intent:{Intent} for message: {MessageId}.", _intent, messageId);
+ request.VoidResult = true;
+ }
+ }
+ }
+ catch (Exception exception)
+ {
+ _logger.LogError(exception, "Error while resolving the intent...");
+ }
+ }
+
+ private async ValueTask HandleIntentMessageAsync(RaiseIntentResolutionRequest? receivedRequest)
+ {
+ if (receivedRequest == null)
+ {
+ LogError("Received null or invalid intent invocation request for intent {Intent}.", _intent);
+ throw ThrowHelper.MissingResponse();
+ }
+
+ var request = new StoreIntentResultRequest
+ {
+ MessageId = receivedRequest.MessageId,
+ Intent = _intent,
+ OriginFdc3InstanceId = _instanceId,
+ TargetFdc3InstanceId = receivedRequest.ContextMetadata.Source!.InstanceId!, //This should be defined
+ };
+
+ try
+ {
+ var context = Deserialize(receivedRequest.Context);
+ if (context == null)
+ {
+ LogError("Received null or invalid context for intent {Intent}. Context: {Context}...", _intent, receivedRequest.Context);
+ throw ThrowHelper.MissingContext();
+ }
+
+ if (ContextTypes.GetType(context.Type) != typeof(T))
+ {
+ request.Error = ResolveError.IntentDeliveryFailed;
+
+ LogDebug("The context type {ContextType} does not match the expected type {ExpectedType} for intent {Intent}. Context: {Context}...", context.Type, typeof(T).Name, _intent, Serialize(context));
+ }
+ else
+ {
+ var intentResult = _intentHandler(context, receivedRequest.ContextMetadata);
+
+ LogDebug("Resolved intent {Intent} with result: {Result}...", _intent, intentResult);
+
+ await SetRequestResultAsync(intentResult, request, receivedRequest.MessageId);
+ }
+ }
+ catch (Exception exception)
+ {
+ _logger.LogError(exception, "Error while resolving the received intent: {Intent}.", _intent);
+ request.Error = ResultError.IntentHandlerRejected;
+ }
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.SendIntentResult,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ LogError("Received null or invalid response when storing the intent result for intent {Intent}. Request: {Request}...", _intent, Serialize(request));
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ LogError("Received error response when storing the intent result for intent {Intent}. Request: {Request}, Response: {Response}...", _intent, Serialize(request), Serialize(response));
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (!response.Stored)
+ {
+ throw ThrowHelper.IntentResultStoreFailed(_intent, _instanceId);
+ }
+ }
+
+ private TType? Deserialize(string json) => JsonSerializer.Deserialize(json, _jsonSerializerOptions);
+
+ private string Serialize(TType obj) => JsonSerializer.Serialize(obj, _jsonSerializerOptions);
+
+ private void LogDebug(string message, params object[] args)
+ {
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug(message, args);
+ }
+ }
+
+ private void LogError(string message, params object[] args)
+ {
+ _logger.LogError(message, args);
+ }
+}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/IntentsClient.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/IntentsClient.cs
new file mode 100644
index 000000000..a5c9a2f46
--- /dev/null
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/IntentsClient.cs
@@ -0,0 +1,290 @@
+/*
+ * 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;
+using Finos.Fdc3;
+using Finos.Fdc3.Context;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions;
+using MorganStanley.ComposeUI.Messaging.Abstractions;
+using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier;
+using IntentResolution = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal.Protocol.IntentResolution;
+
+namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal;
+
+internal class IntentsClient : IIntentsClient
+{
+ private static int _messageIdCounter = 0;
+
+ private readonly IMessaging _messaging;
+ private readonly IChannelFactory _channelFactory;
+ private readonly string _instanceId;
+ private readonly ILoggerFactory _loggerFactory;
+ private readonly ILogger _logger;
+ private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization;
+
+ public IntentsClient(
+ IMessaging messaging,
+ IChannelFactory channelFactory,
+ string instanceId,
+ ILoggerFactory? loggerFactory = null)
+ {
+ _messaging = messaging;
+ _channelFactory = channelFactory;
+ _instanceId = instanceId;
+ _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
+ _logger = _loggerFactory.CreateLogger();
+ }
+
+ public async ValueTask AddIntentListenerAsync(string intent, IntentHandler handler) where T : IContext
+ {
+ var listener = new IntentListener(
+ _messaging,
+ intent,
+ _instanceId,
+ handler,
+ _loggerFactory.CreateLogger>());
+
+ await listener.RegisterIntentHandlerAsync();
+
+ var request = new IntentListenerRequest
+ {
+ Fdc3InstanceId = _instanceId,
+ Intent = intent,
+ State = SubscribeState.Subscribe
+ };
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.AddIntentListener,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (!response.Stored)
+ {
+ throw ThrowHelper.ListenerNotRegistered(intent, _instanceId);
+ }
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Listener is registered...");
+ }
+
+ return listener;
+ }
+
+ public async ValueTask FindIntentAsync(string intent, IContext? context = null, string? resultType = null)
+ {
+ var request = new FindIntentRequest
+ {
+ Fdc3InstanceId = _instanceId,
+ Intent = intent,
+ Context = context?.Type,
+ ResultType = resultType
+ };
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Finding intent. Intent: {Intent}, Context Type: {ContextType}, Result Type: {ResultType}", intent, context?.Type, resultType);
+ }
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.FindIntent,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (response.AppIntent == null)
+ {
+ throw ThrowHelper.DesktopAgentBackendDidNotResolveRequest(nameof(FindIntentRequest), nameof(response.AppIntent), Fdc3DesktopAgentErrors.NoAppIntent);
+ }
+
+ return response.AppIntent;
+ }
+
+ public async ValueTask> FindIntentsByContextAsync(IContext context, string? resultType = null)
+ {
+ var request = new FindIntentsByContextRequest
+ {
+ Fdc3InstanceId = _instanceId,
+ Context = JsonSerializer.Serialize(context, _jsonSerializerOptions),
+ ResultType = resultType
+ };
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Finding intents by context. Context Type: {ContextType}, Result Type: {ResultType}", context.Type, resultType);
+ }
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.FindIntentsByContext,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (response.AppIntents == null)
+ {
+ throw ThrowHelper.AppIntentMissingFromResponse("null", context.Type, resultType);
+ }
+
+ return response.AppIntents;
+ }
+
+ public async ValueTask RaiseIntentAsync(string intent, IContext context, IAppIdentifier? app)
+ {
+ var messageId = Interlocked.Increment(ref _messageIdCounter);
+
+ var request = new RaiseIntentRequest
+ {
+ MessageId = messageId,
+ Fdc3InstanceId = _instanceId,
+ Intent = intent,
+ Context = JsonSerializer.Serialize(context, _jsonSerializerOptions),
+ };
+
+ if (app != null)
+ {
+ request.TargetAppIdentifier = new AppIdentifier
+ {
+ AppId = app.AppId,
+ InstanceId = app.InstanceId
+ };
+ }
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Request is created: {Request}", JsonSerializer.Serialize(request, _jsonSerializerOptions));
+ }
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.RaiseIntent,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (string.IsNullOrEmpty(response.MessageId)
+ || string.IsNullOrEmpty(response.Intent)
+ || response.AppMetadata == null)
+ {
+ throw ThrowHelper.IntentResolutionIsNotDefined(context.Type, app?.AppId, app?.InstanceId, intent);
+ }
+
+ var intentResolution = new IntentResolution(
+ response.MessageId!,
+ _messaging,
+ _channelFactory,
+ response.Intent!,
+ response.AppMetadata!,
+ _loggerFactory.CreateLogger());
+
+ return intentResolution;
+ }
+
+ public async ValueTask RaiseIntentForContextAsync(IContext context, IAppIdentifier? app)
+ {
+ var messageId = Interlocked.Increment(ref _messageIdCounter);
+
+ var request = new RaiseIntentForContextRequest
+ {
+ Fdc3InstanceId = _instanceId,
+ Context = JsonSerializer.Serialize(context, _jsonSerializerOptions),
+ MessageId = messageId
+ };
+
+ if (app != null)
+ {
+ request.TargetAppIdentifier = new AppIdentifier
+ {
+ AppId = app.AppId,
+ InstanceId = app.InstanceId
+ };
+ }
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Request is created: {Request}", JsonSerializer.Serialize(request, _jsonSerializerOptions));
+ }
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.RaiseIntentForContext,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (string.IsNullOrEmpty(response.MessageId)
+ || string.IsNullOrEmpty(response.Intent)
+ || response.AppMetadata == null)
+ {
+ throw ThrowHelper.IntentResolutionIsNotDefined(context.Type, app?.AppId, app?.InstanceId);
+ }
+
+ var intentResolution = new IntentResolution(
+ response.MessageId!,
+ _messaging,
+ _channelFactory,
+ response.Intent!,
+ response.AppMetadata!,
+ _loggerFactory.CreateLogger());
+
+ return intentResolution;
+ }
+}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/MetadataClient.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/MetadataClient.cs
index 280137067..c42fdacdc 100644
--- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/MetadataClient.cs
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/MetadataClient.cs
@@ -20,6 +20,7 @@
using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts;
using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions;
using MorganStanley.ComposeUI.Messaging.Abstractions;
+using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier;
namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal;
@@ -43,6 +44,41 @@ public MetadataClient(
_logger = logger ?? NullLogger.Instance;
}
+ public async ValueTask> FindInstancesAsync(IAppIdentifier appIdentifier)
+ {
+ var request = new FindInstancesRequest
+ {
+ Fdc3InstanceId = _instanceId,
+ AppIdentifier = new AppIdentifier
+ {
+ AppId = appIdentifier.AppId,
+ InstanceId = appIdentifier.InstanceId,
+ }
+ };
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.FindInstances,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (response.Instances == null)
+ {
+ throw ThrowHelper.DesktopAgentBackendDidNotResolveRequest(nameof(FindInstancesRequest), nameof(response.Instances), Fdc3DesktopAgentErrors.NoInstanceFound);
+ }
+
+ return response.Instances;
+ }
+
public async ValueTask GetAppMetadataAsync(IAppIdentifier appIdentifier)
{
var request = new GetAppMetadataRequest
@@ -67,13 +103,13 @@ public async ValueTask GetAppMetadataAsync(IAppIdentifier appIdent
if (response == null)
{
- _logger.LogError("{GetAppMetadataAsync} response is null returned by the server...", nameof(GetAppMetadataAsync));
+ _logger.LogError("{Method} response is null returned by the server...", nameof(GetAppMetadataAsync));
throw ThrowHelper.MissingResponse();
}
if (response.Error != null)
{
- _logger.LogError("{_appId} cannot return the {AppMetadata} for {AppId} due to: {Error}.", nameof(AppMetadata), appIdentifier.AppId, response.Error);
+ _logger.LogError("{AppId} cannot return the {AppMetadata} for {TargetAppId} due to: {Error}.", _appId, nameof(AppMetadata), appIdentifier.AppId, response.Error);
throw ThrowHelper.ErrorResponseReceived(_appId, appIdentifier.AppId, nameof(AppMetadata), response.Error);
}
@@ -98,7 +134,7 @@ public async ValueTask GetInfoAsync()
if (response == null)
{
- _logger.LogError("{GetInfoAsync} response is null returned by the server...", nameof(GetInfoAsync));
+ _logger.LogError("{Method} response is null returned by the server...", nameof(GetInfoAsync));
throw ThrowHelper.MissingResponse();
}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/OpenClient.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/OpenClient.cs
new file mode 100644
index 000000000..51a44b8ac
--- /dev/null
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/OpenClient.cs
@@ -0,0 +1,155 @@
+/*
+ * 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;
+using Finos.Fdc3;
+using Finos.Fdc3.Context;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions;
+using MorganStanley.ComposeUI.Messaging.Abstractions;
+using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier;
+
+namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal;
+
+internal class OpenClient : IOpenClient
+{
+ private readonly string _instanceId;
+ private readonly IMessaging _messaging;
+ private readonly IDesktopAgent _desktopAgent;
+ private readonly ILogger _logger;
+ private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization;
+
+ public OpenClient(
+ string instanceId,
+ IMessaging messaging,
+ IDesktopAgent desktopAgent,
+ ILogger? logger = null)
+ {
+ _instanceId = instanceId;
+ _messaging = messaging;
+ _desktopAgent = desktopAgent;
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async ValueTask GetOpenAppContextAsync(string openAppContextId)
+ {
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("OpenClient: Retrieving open app context for app {InstanceId}...", _instanceId);
+ }
+
+ if (string.IsNullOrEmpty(openAppContextId))
+ {
+ throw ThrowHelper.MissingOpenAppContext();
+ }
+
+ var request = new GetOpenedAppContextRequest
+ {
+ ContextId = openAppContextId
+ };
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.GetOpenedAppContext,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (string.IsNullOrEmpty(response.Context))
+ {
+ throw ThrowHelper.MissingOpenedAppContext();
+ }
+
+ var context = JsonSerializer.Deserialize(response.Context!, _jsonSerializerOptions);
+
+ if (context == null)
+ {
+ throw ThrowHelper.MissingOpenedAppContext();
+ }
+
+ return context;
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async ValueTask OpenAsync(IAppIdentifier app, IContext? context)
+ {
+ if (context != null
+ && string.IsNullOrEmpty(context.Type))
+ {
+ throw ThrowHelper.MalformedContext();
+ }
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("OpenClient: Opening app {App} with context {Context}", app, context);
+ }
+
+ var currentChannel = await _desktopAgent.GetCurrentChannel();
+
+ var request = new OpenRequest
+ {
+ InstanceId = _instanceId,
+ AppIdentifier = new AppIdentifier
+ {
+ AppId = app.AppId,
+ InstanceId = app.InstanceId
+ },
+ Context = JsonSerializer.Serialize(context, _jsonSerializerOptions),
+ ChannelId = currentChannel?.Id
+ };
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.Open,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (response.AppIdentifier == null)
+ {
+ throw ThrowHelper.AppIdentifierNotRetrieved();
+ }
+
+ return response.AppIdentifier;
+ }
+}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/PrivateChannelContextListenerEventListener.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/PrivateChannelContextListenerEventListener.cs
new file mode 100644
index 000000000..6eb0f9d3f
--- /dev/null
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/PrivateChannelContextListenerEventListener.cs
@@ -0,0 +1,93 @@
+/*
+ * 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 Finos.Fdc3;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal;
+
+internal delegate void PrivateChannelEventHandler(string? contextType);
+internal delegate void PrivateChannelOnUnsubscribeHandler(PrivateChannelContextListenerEventListener privateChannelContextListener);
+
+internal class PrivateChannelContextListenerEventListener : IListener
+{
+ private PrivateChannelEventHandler _handler;
+ private PrivateChannelOnUnsubscribeHandler _unsubscribeCallback;
+ private readonly ILogger _logger;
+ private readonly SemaphoreSlim _semaphoreSlim = new(1, 1);
+ private bool _subscribed;
+
+ public PrivateChannelContextListenerEventListener(
+ PrivateChannelEventHandler onContext,
+ PrivateChannelOnUnsubscribeHandler onUnsubscribe,
+ ILogger? logger = null)
+ {
+ _handler = onContext;
+ _unsubscribeCallback = onUnsubscribe;
+ _subscribed = true;
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ public void Execute(string? contextType)
+ {
+ try
+ {
+ _semaphoreSlim.Wait();
+
+ if (_subscribed)
+ {
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Invoking context listener for context type '{ContextType}'.", contextType);
+ }
+
+ _handler(contextType);
+ }
+ }
+ finally
+ {
+ _semaphoreSlim.Release();
+ }
+ }
+
+ public void Unsubscribe()
+ {
+ UnsubscribeCore(true);
+ }
+
+ internal void UnsubscribeCore(bool doCallback)
+ {
+ try
+ {
+ _semaphoreSlim.Wait();
+
+ _subscribed = false;
+
+ if (doCallback)
+ {
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Unsubscribing {NameOfPrivateChannelContextListenerEventListener}.", nameof(PrivateChannelContextListenerEventListener));
+ }
+
+ _unsubscribeCallback(this);
+ }
+ }
+ finally
+ {
+ _semaphoreSlim.Release();
+ }
+ }
+}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/PrivateChannelDisconnectEventListener.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/PrivateChannelDisconnectEventListener.cs
new file mode 100644
index 000000000..c1927fdcd
--- /dev/null
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/PrivateChannelDisconnectEventListener.cs
@@ -0,0 +1,90 @@
+/*
+ * 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 Finos.Fdc3;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal;
+
+internal delegate void PrivateChannelDisconnectEventHandler();
+internal delegate void PrivateChannelUnsubscribeHandler(PrivateChannelDisconnectEventListener listener);
+
+internal class PrivateChannelDisconnectEventListener : IListener
+{
+ private readonly PrivateChannelDisconnectEventHandler _onDisconnect;
+ private readonly PrivateChannelUnsubscribeHandler _onUnsubscribe;
+ private readonly ILogger _logger;
+ private readonly SemaphoreSlim _semaphoreSlim = new(1, 1);
+ private bool _subscribed;
+
+ public PrivateChannelDisconnectEventListener(
+ PrivateChannelDisconnectEventHandler onDisconnectEventHandler,
+ PrivateChannelUnsubscribeHandler onUnsubscribeHandler,
+ ILogger? logger = null)
+ {
+ _onDisconnect = onDisconnectEventHandler;
+ _onUnsubscribe = onUnsubscribeHandler;
+ _subscribed = true;
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ public void Execute()
+ {
+ try
+ {
+ _semaphoreSlim.Wait();
+
+ if (!_subscribed)
+ {
+ return;
+ }
+
+ _onDisconnect();
+ }
+ finally
+ {
+ _semaphoreSlim.Release();
+ }
+ }
+
+ public void Unsubscribe()
+ {
+ UnsubscribeCore(true);
+ }
+
+ internal void UnsubscribeCore(bool doCallback)
+ {
+ try
+ {
+ _semaphoreSlim.Wait();
+
+ _subscribed = false;
+
+ if (doCallback)
+ {
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Unsubscribing {NameOfPrivateChannelDisconnectEventHandler}.", nameof(PrivateChannelDisconnectEventHandler));
+ }
+
+ _onUnsubscribe(this);
+ }
+ }
+ finally
+ {
+ _semaphoreSlim.Release();
+ }
+ }
+}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Channel.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/Channel.cs
similarity index 89%
rename from src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Channel.cs
rename to src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/Channel.cs
index 345cc5b23..867bd6366 100644
--- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Channel.cs
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/Channel.cs
@@ -32,10 +32,10 @@ internal class Channel : IChannel
private readonly ChannelType _channelType;
private readonly string _instanceId;
private readonly IMessaging _messaging;
+ private readonly IContext? _openedAppContext;
private readonly DisplayMetadata? _displayMetadata;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
-
private readonly SemaphoreSlim _lastContextLock = new(1,1);
private IContext? _lastContext = null;
private readonly ConcurrentDictionary _lastContexts = new();
@@ -46,6 +46,7 @@ public Channel(
ChannelType channelType,
IMessaging messaging,
string instanceId,
+ IContext? openedAppContext = null,
DisplayMetadata? displayMetadata = null,
ILoggerFactory? loggerFactory = null)
{
@@ -53,6 +54,7 @@ public Channel(
_channelType = channelType;
_instanceId = instanceId;
_messaging = messaging;
+ _openedAppContext = openedAppContext;
_displayMetadata = displayMetadata;
_loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
_logger = _loggerFactory.CreateLogger();
@@ -64,20 +66,25 @@ public Channel(
public IDisplayMetadata? DisplayMetadata => _displayMetadata;
- public async Task AddContextListener(string? contextType, ContextHandler handler) where T : IContext
+ protected ILoggerFactory LoggerFactory => _loggerFactory;
+ protected IMessaging Messaging => _messaging;
+ protected string InstanceId => _instanceId;
+
+ public virtual async Task AddContextListener(string? contextType, ContextHandler handler) where T : IContext
{
var listener = new ContextListener(
instanceId: _instanceId,
contextHandler: handler,
messaging: _messaging,
contextType: contextType,
+ openedAppContext: _openedAppContext,
logger: _loggerFactory.CreateLogger>());
await listener.SubscribeAsync(_channelId, _channelType);
return listener;
}
- public async Task Broadcast(IContext context)
+ public virtual async Task Broadcast(IContext context)
{
try
{
@@ -101,7 +108,7 @@ await _messaging.PublishJsonAsync(
}
}
- public async Task GetCurrentContext(string? contextType)
+ public virtual async Task GetCurrentContext(string? contextType)
{
try
{
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/IntentResolution.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/IntentResolution.cs
new file mode 100644
index 000000000..a90951d74
--- /dev/null
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/IntentResolution.cs
@@ -0,0 +1,109 @@
+/*
+ * 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;
+using Finos.Fdc3;
+using Finos.Fdc3.Context;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions;
+using MorganStanley.ComposeUI.Messaging.Abstractions;
+using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier;
+
+namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal.Protocol;
+
+internal class IntentResolution : IIntentResolution
+{
+ private readonly string _messageId;
+ private readonly IMessaging _messaging;
+ private readonly IChannelFactory _channelFactory;
+ private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization;
+
+ public IntentResolution(
+ string messageId,
+ IMessaging messaging,
+ IChannelFactory channelFactory,
+ string intent,
+ IAppIdentifier source,
+ ILogger? logger = null)
+ {
+ _messageId = messageId;
+ _messaging = messaging;
+ _channelFactory = channelFactory;
+ Intent = intent;
+ Source = source;
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ public IAppIdentifier Source { get; }
+
+ private readonly ILogger _logger;
+
+ public string Intent { get; }
+
+ public string? Version { get; }
+
+ public async Task GetResult()
+ {
+ var request = new GetIntentResultRequest
+ {
+ MessageId = _messageId,
+ TargetAppIdentifier = new AppIdentifier
+ {
+ AppId = Source.AppId,
+ InstanceId = Source.InstanceId
+ },
+ Intent = Intent,
+ Version = Version
+ };
+
+ var response = await _messaging.InvokeJsonServiceAsync(
+ Fdc3Topic.GetIntentResult,
+ request,
+ _jsonSerializerOptions);
+
+ if (response == null)
+ {
+ throw ThrowHelper.MissingResponse();
+ }
+
+ if (!string.IsNullOrEmpty(response.Error))
+ {
+ throw ThrowHelper.ErrorResponseReceived(response.Error);
+ }
+
+ if (!string.IsNullOrEmpty(response.ChannelId)
+ && response.ChannelType != null)
+ {
+ var channel = await _channelFactory.FindChannelAsync(response.ChannelId!, response.ChannelType.Value);
+ return channel;
+ }
+ else if (!string.IsNullOrEmpty(response.Context))
+ {
+ var context = JsonSerializer.Deserialize(response.Context!, _jsonSerializerOptions);
+ return context;
+ }
+ else if (response.VoidResult != null
+ && response.VoidResult.Value)
+ {
+ _logger.LogDebug("The intent result is void for intent:{Intent} for message: {MessageId}.", Intent, _messageId);
+
+ return null;
+ }
+
+ throw ThrowHelper.IntentResolutionFailed(Intent, _messageId, Source);
+ }
+}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/PrivateChannel.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/PrivateChannel.cs
new file mode 100644
index 000000000..a8fca2423
--- /dev/null
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/PrivateChannel.cs
@@ -0,0 +1,554 @@
+/*
+ * 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;
+using Finos.Fdc3;
+using Finos.Fdc3.Context;
+using Microsoft.Extensions.Logging;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions;
+using MorganStanley.ComposeUI.Messaging.Abstractions;
+using DisplayMetadata = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.DisplayMetadata;
+
+namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal.Protocol;
+
+internal class PrivateChannel : Channel, IPrivateChannel, IAsyncDisposable
+{
+ private readonly ILogger _logger;
+ private readonly PrivateChannelTopics _privateChannelTopics;
+ private readonly Action _onDisconnect;
+ private readonly string _internalEventsTopic;
+ private readonly bool _isOriginalCreator;
+ private readonly string _remoteContextListenersService;
+ private readonly TaskCompletionSource _initializationTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ private readonly SemaphoreSlim _lock = new(1, 1);
+ private readonly List _contextHandlers = new();
+ private readonly List _addContextListenerHandlers = new();
+ private readonly List _unsubscribeHandlers = new();
+ private readonly List _disconnectHandlers = new();
+
+ private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization;
+
+ private IAsyncDisposable _subscription;
+ private IAsyncDisposable _serviceRegistration;
+ private bool _isDisconnected = false;
+
+ public PrivateChannel(
+ string channelId,
+ IMessaging messaging,
+ string instanceId,
+ bool isOriginalCreator,
+ Action onDisconnect,
+ DisplayMetadata? displayMetadata = null,
+ ILoggerFactory? loggerFactory = null)
+ : base(channelId, ChannelType.Private, messaging, instanceId, null, displayMetadata, loggerFactory)
+ {
+ _privateChannelTopics = Fdc3Topic.PrivateChannel(channelId);
+ _internalEventsTopic = _privateChannelTopics.Events;
+ _isOriginalCreator = isOriginalCreator;
+ _remoteContextListenersService = _privateChannelTopics.GetContextHandlers(!isOriginalCreator);
+ _onDisconnect = onDisconnect;
+ _logger = LoggerFactory.CreateLogger();
+
+ Task.Run(() => InitializeAsync());
+ }
+
+ private async ValueTask InitializeAsync()
+ {
+ try
+ {
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Subscribing to private channel internal events for channel {ChannelId}, instance {InstanceId}, topic: {Topic}.", Id, InstanceId, _internalEventsTopic);
+ }
+
+ _subscription = await Messaging.SubscribeAsync(_internalEventsTopic, HandleInternalEvent);
+
+
+ var topic = _privateChannelTopics.GetContextHandlers(_isOriginalCreator);
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Registering remote private channel context handlers service for channel {ChannelId}, instance {InstanceId}, service: {Service}.", Id, InstanceId, topic);
+ }
+
+ _serviceRegistration = await Messaging.RegisterServiceAsync(topic, HandleRemoteContextListener);
+
+ _initializationTaskCompletionSource.SetResult(Id);
+ }
+ catch (Exception exception)
+ {
+ _initializationTaskCompletionSource.SetException(exception);
+ }
+ }
+
+ public override async Task Broadcast(IContext context)
+ {
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ try
+ {
+ await _lock.WaitAsync().ConfigureAwait(false);
+
+ if (_isDisconnected)
+ {
+ throw ThrowHelper.PrivateChannelDisconnected(Id, InstanceId);
+ }
+
+ await base.Broadcast(context);
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public override async Task AddContextListener(string? contextType, ContextHandler handler)
+ {
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ try
+ {
+ await _lock.WaitAsync().ConfigureAwait(false);
+
+ if (_isDisconnected)
+ {
+ throw ThrowHelper.PrivateChannelDisconnected(Id, InstanceId);
+ }
+
+ var listener = await base.AddContextListener(contextType, handler) as ContextListener;
+
+ if (listener != null)
+ {
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Setting unsubscribe callback for {ListenerType} when adding the listener on the {ChannelType}...", nameof(ContextListener), nameof(PrivateChannel));
+ }
+
+ listener.SetUnsubscribeCallback(contextListener => RemoveContextHandler(contextListener));
+
+ _contextHandlers.Add(listener);
+
+ _ = FireContextHandlerAdded(contextType);
+
+ return listener;
+ }
+
+ throw ThrowHelper.PrivatChannelSubscribeFailure(contextType, Id, InstanceId);
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public async void Disconnect()
+ {
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ try
+ {
+ await _lock.WaitAsync().ConfigureAwait(false);
+
+ if (_isDisconnected)
+ {
+ return;
+ }
+
+ _isDisconnected = true;
+ foreach (var handler in _addContextListenerHandlers)
+ {
+ handler.UnsubscribeCore(false);
+
+ if (_logger.IsEnabled(LogLevel.Trace))
+ {
+ _logger.LogTrace("Unsubscribed add context listener handler on private channel {ChannelId}.", Id);
+ }
+ }
+
+ foreach (var handler in _unsubscribeHandlers)
+ {
+ handler.UnsubscribeCore(false);
+
+ if (_logger.IsEnabled(LogLevel.Trace))
+ {
+ _logger.LogTrace("Unsubscribed unsubscribe handler on private channel {ChannelId}.", Id);
+ }
+ }
+
+ foreach (var handler in _disconnectHandlers)
+ {
+ handler.UnsubscribeCore(false);
+
+ if (_logger.IsEnabled(LogLevel.Trace))
+ {
+ _logger.LogTrace("Unsubscribed disconnect handler on private channel {ChannelId}.", Id);
+ }
+ }
+
+ _addContextListenerHandlers.Clear();
+ _unsubscribeHandlers.Clear();
+ _disconnectHandlers.Clear();
+
+ foreach (var listener in _contextHandlers)
+ {
+ //Fire and forget
+ listener.Unsubscribe();
+
+ if (_logger.IsEnabled(LogLevel.Trace))
+ {
+ _logger.LogTrace("Unsubscribed context listener on private channel {ChannelId}.", Id);
+ }
+ }
+
+ var request = PrivateChannelInternalEvents.Disconnected(InstanceId);
+ var serializedRequest = JsonSerializer.Serialize(request, _jsonSerializerOptions);
+
+ await Messaging.PublishAsync(_internalEventsTopic, serializedRequest);
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Private channel {ChannelId} disconnected. Request: {Request} has been sent to the backend...", Id, serializedRequest);
+ }
+
+ _onDisconnect();
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public IListener OnAddContextListener(Action handler)
+ {
+ try
+ {
+ _lock.Wait();
+
+ if (_isDisconnected)
+ {
+ throw ThrowHelper.PrivateChannelDisconnected(Id, InstanceId);
+ }
+
+ var listener = new PrivateChannelContextListenerEventListener(
+ onContext: (contextType) => handler(contextType),
+ onUnsubscribe: RemoveAddContextListenerHandler,
+ logger: LoggerFactory.CreateLogger());
+
+ _addContextListenerHandlers.Add(listener);
+
+ //Fire and forget
+ _ = ExecuteForRemoteContextHandlers(listener);
+
+ return listener;
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public IListener OnDisconnect(Action handler)
+ {
+ try
+ {
+ _lock.Wait();
+
+ if (_isDisconnected)
+ {
+ throw ThrowHelper.PrivateChannelDisconnected(Id, InstanceId);
+ }
+
+ var listener = new PrivateChannelDisconnectEventListener(
+ onDisconnectEventHandler: () => handler(),
+ onUnsubscribeHandler: RemoveDisconnectListenerHandler,
+ logger: LoggerFactory.CreateLogger());
+
+ _disconnectHandlers.Add(listener);
+
+ if (_logger.IsEnabled(LogLevel.Trace))
+ {
+ _logger.LogTrace("Added disconnect handler on private channel {ChannelId}.", Id);
+ }
+
+ return listener;
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public IListener OnUnsubscribe(Action handler)
+ {
+ try
+ {
+ _lock.Wait();
+
+ if (_isDisconnected)
+ {
+ throw ThrowHelper.PrivateChannelDisconnected(Id, InstanceId);
+ }
+
+ var listener = new PrivateChannelContextListenerEventListener(
+ onContext: (contextType) => handler(contextType),
+ onUnsubscribe: RemoveUnsubscribeListenerHandler,
+ logger: LoggerFactory.CreateLogger());
+
+ _unsubscribeHandlers.Add(listener);
+
+ if (_logger.IsEnabled(LogLevel.Trace))
+ {
+ _logger.LogTrace("Added unsubscribe handler on private channel {ChannelId}.", Id);
+ }
+
+ return listener;
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ private async ValueTask ExecuteForRemoteContextHandlers(PrivateChannelContextListenerEventListener listener)
+ {
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ try
+ {
+ var listeners = await Messaging.InvokeServiceAsync(_remoteContextListenersService);
+
+ var remoteListeners = string.IsNullOrEmpty(listeners)
+ ? Array.Empty()
+ : JsonSerializer.Deserialize>(listeners!, _jsonSerializerOptions);
+
+ foreach (var contextType in remoteListeners ?? Array.Empty())
+ {
+ listener.Execute(contextType);
+
+ if (_logger.IsEnabled(LogLevel.Trace))
+ {
+ _logger.LogTrace("Executed remote context handler for context type {ContextType} on private channel {ChannelId} for remote listener.", contextType, Id);
+ }
+ }
+ }
+ catch (Exception exception)
+ {
+ _logger.LogError(exception, "Error executing remote context handlers on private channel: {ChannelId}, {InstanceId}.", Id, InstanceId);
+ return;
+ }
+ }
+
+ private void RemoveUnsubscribeListenerHandler(PrivateChannelContextListenerEventListener listener)
+ {
+ try
+ {
+ _lock.Wait();
+
+ if (!_unsubscribeHandlers.Remove(listener))
+ {
+ _logger.LogWarning("The {ListenerType} to remove was not found in the list of registered {ContextListenerType}.", nameof(PrivateChannelContextListenerEventListener), nameof(PrivateChannelContextListenerEventListener));
+ }
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ private void RemoveDisconnectListenerHandler(PrivateChannelDisconnectEventListener listener)
+ {
+ try
+ {
+ _lock.Wait();
+
+ if (!_disconnectHandlers.Remove(listener))
+ {
+ _logger.LogWarning("The {ListenerType} to remove was not found in the list of registered {DisconnectListenerType}.", nameof(PrivateChannelDisconnectEventListener), nameof(PrivateChannelDisconnectEventListener));
+ }
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ private void RemoveAddContextListenerHandler(PrivateChannelContextListenerEventListener privateChannelContextListener)
+ {
+ try
+ {
+ _lock.Wait();
+
+ if (!_addContextListenerHandlers.Remove(privateChannelContextListener))
+ {
+ _logger.LogWarning("The {ListenerType} to remove was not found in the list of registered {ContextListenerType}.", nameof(PrivateChannelContextListenerEventListener), nameof(PrivateChannelContextListenerEventListener));
+ }
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ private async ValueTask FireContextHandlerAdded(string? contextType)
+ {
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ var request = new PrivateChannelInternalEvents
+ {
+ ContextType = contextType,
+ Event = PrivateChannelInternalEventType.ContextListenerAdded,
+ InstanceId = InstanceId
+ };
+
+ var serializedRequest = JsonSerializer.Serialize(request, _jsonSerializerOptions);
+
+ await Messaging.PublishAsync(_internalEventsTopic, serializedRequest);
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Fired context handler added event for context type {ContextType} on private channel {ChannelId}. Request has been sent to backend: {Request}.", contextType, Id, serializedRequest);
+ }
+ }
+
+ private void RemoveContextHandler(ContextListener contextListener) where T : IContext
+ {
+ try
+ {
+ _lock.Wait();
+
+ if (!_isDisconnected)
+ {
+ if (_contextHandlers.Remove(contextListener))
+ {
+ _logger.LogWarning("The context listener for context type {ContextType} was removed from private channel {ChannelId}.", contextListener.ContextType, Id);
+ }
+ }
+ //Fire and forget
+ _ = FireUnsubscribed(contextListener.ContextType);
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ private async ValueTask FireUnsubscribed(string? contextType)
+ {
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ var request = new PrivateChannelInternalEvents
+ {
+ ContextType = contextType,
+ Event = PrivateChannelInternalEventType.Unsubscribed,
+ InstanceId = InstanceId
+ };
+
+ var serializedRequest = JsonSerializer.Serialize(request, _jsonSerializerOptions);
+
+ await Messaging.PublishAsync(_internalEventsTopic, serializedRequest);
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Fired unsubscribed event for context type {ContextType} on private channel {ChannelId}. Request has been sent to backend: {Request}.", contextType, Id, serializedRequest);
+ }
+ }
+
+ private async ValueTask HandleRemoteContextListener(string? request)
+ {
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ try
+ {
+ await _lock.WaitAsync().ConfigureAwait(false);
+
+ var contextListeners = _contextHandlers.AsEnumerable();
+
+ return JsonSerializer.Serialize(contextListeners, _jsonSerializerOptions);
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ private async ValueTask HandleInternalEvent(string payload)
+ {
+ try
+ {
+ await _lock.WaitAsync().ConfigureAwait(false);
+
+ if (_isDisconnected
+ || string.IsNullOrEmpty(payload))
+ {
+ return;
+ }
+
+ var internalEvent = JsonSerializer.Deserialize(payload, _jsonSerializerOptions);
+
+ if (internalEvent == null)
+ {
+ _logger.LogWarning("Received invalid internal event on private channel {ChannelId}: {Payload}", Id, payload);
+ return;
+ }
+
+ if (internalEvent.InstanceId == InstanceId)
+ {
+ // Ignore events originating from this instance
+ if (_logger.IsEnabled(LogLevel.Trace))
+ {
+ _logger.LogTrace("Ignoring internal event from same instance {InstanceId} on private channel {ChannelId}. Event: {Payload}", InstanceId, Id, payload);
+ }
+
+ return;
+ }
+
+ if (internalEvent.Event == PrivateChannelInternalEventType.ContextListenerAdded)
+ {
+ foreach (var handler in _addContextListenerHandlers)
+ {
+ handler.Execute(internalEvent.ContextType);
+ }
+ }
+ else if (internalEvent.Event == PrivateChannelInternalEventType.Unsubscribed)
+ {
+ foreach (var handler in _unsubscribeHandlers)
+ {
+ handler.Execute(internalEvent.ContextType);
+ }
+ }
+ else if (internalEvent.Event == PrivateChannelInternalEventType.Disconnected)
+ {
+ foreach (var handler in _disconnectHandlers)
+ {
+ handler.Execute();
+ }
+ }
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _initializationTaskCompletionSource.Task.ConfigureAwait(false);
+
+ Disconnect();
+ await _serviceRegistration.DisposeAsync().ConfigureAwait(false);
+ await _subscription.DisposeAsync().ConfigureAwait(false);
+ }
+}
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/PrivateChannelInternalEventType.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/PrivateChannelInternalEventType.cs
new file mode 100644
index 000000000..75071a787
--- /dev/null
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/PrivateChannelInternalEventType.cs
@@ -0,0 +1,22 @@
+/*
+ * 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.Fdc3.DesktopAgent.Client.Infrastructure.Internal.Protocol;
+
+internal static class PrivateChannelInternalEventType
+{
+ public static string ContextListenerAdded = "contextListenerAdded";
+ public static string Unsubscribed = "unsubscribed";
+ public static string? Disconnected = "disconnected";
+}
\ No newline at end of file
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.csproj b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.csproj
index b03a6e4f8..855565069 100644
--- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.csproj
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.csproj
@@ -2,7 +2,7 @@
netstandard2.0
- .Net Standard 2.0 Native client for FDC3 operations implementing the IDesktopAgentClient from the Finos.Fdc3 NuGet package. More Details: https://morganstanley.github.io/ComposeUI/
+ .Net Standard 2.0 Native client for FDC3 operations implementing the IDesktopAgent from the Finos.Fdc3 NuGet package. More Details: https://morganstanley.github.io/ComposeUI/
true
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/README.md b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/README.md
index 4544691ae..c88b419fc 100644
--- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/README.md
+++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/README.md
@@ -9,6 +9,22 @@ It enables .NET applications to interact with FDC3-compliant desktop agents, fac
- Integrates with ComposeUI messaging abstraction (`IMessaging`), so it doesn't rely on any actual messaging implementation.
- Extensible logging support via `ILogger`.
+
+## Target Framework
+- .NET Standard 2.0
+
+Compatible with .NET (Core), .NET Framework.
+
+
+## Dependencies
+- [Finos.Fdc3](https://www.nuget.org/packages/Finos.Fdc3)
+- [Finos.Fdc3.AppDirectory](https://www.nuget.org/packages/Finos.Fdc3.AppDirectory)
+- [Microsoft.Extensions.Logging.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.Logging.Abstractions)
+- [System.Text.Json](https://www.nuget.org/packages/System.Text.Json)
+- [MorganStanley.ComposeUI.Messaging.Abstractions](https://www.nuget.org/packages/MorganStanley.ComposeUI.Messaging.Abstractions)
+- [MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared](https://www.nuget.org/packages/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared)
+
+
## Installation
Add a reference to the NuGet package (if available) or include the project in your solution.
@@ -131,6 +147,97 @@ You can get the metadata of an app using the GetAppMetadata method by providing
var appMetadata = await desktopAgent.GetAppMetadata(new AppIdentifier("your-app-id", "your-instance-id"));
```
+### Getting User Channels
+You can retrieve user channels by using:
+```csharp
+var channels = await desktopAgent.GetUserChannels();
+await desktopAgent.JoinUserChannel(channels[0].Id);
+```
+
+### Getting or Creating a App Channel
+You can get or create an app channel by using:
+```csharp
+var appChannel = await desktopAgent.GetOrCreateChannel("your-app-channel-id");
+var listener = await appChannel.AddContextListener("fdc3.instrument", (ctx, ctxMetadata) =>
+{
+ Console.WriteLine($"Received context on app channel: {ctx}");
+});
+
+//Initiator shouldn't receive back the broadcasted context
+await appChannel.Broadcast(context);
+```
+
+### Finding apps based on the specified intent
+You can find/search for applications from the AppDirectory by using the `FindIntent` function:
+```csharp
+var apps = await desktopAgent.FindIntent("ViewChart", new Instrument(new InstrumentID { Ticker = "AAPL" }), "expected_resultType"));
+```
+
+### Finding instances for the specified app
+You can find the currently FDC3 enabled instances for the specified app by using the `FindInstances` function:
+```csharp
+var instances = await desktopAgent.FindInstances("your-app-id");
+```
+
+### Finding intents by context
+You can find the apps that can handle for the specified context by using the `FindIntentsByContext` function:
+```csharp
+var context = new Instrument(new InstrumentID { Ticker = "AAPL" }, "Apple Inc.");
+var appIntents = await desktopAgent.FindIntentsByContext(context);
+```
+
+### Raising intent for context
+You can raise an intent for the specified context by using the `RaiseIntentForContext` function and return its result by using the `GetResult` of the returned `IIntentResolution`:
+```csharp
+var intentResolution = await desktopAgent.RaiseIntentForContext(context, appIdentifier);
+var intentResult = await intentResolution.GetResult();
+```
+
+### Adding Intent Listener
+You can register an intent listener by using the `AddIntentListener` function:
+```csharp
+var currentChannel = await desktopAgent.GetCurrentChannel();
+var listener = await desktopAgent.AddIntentListener("ViewChart", async (ctx, ctxMetadata) =>
+{
+ Console.WriteLine($"Received intent with context: {ctx}");
+ return currentChannel;
+});
+```
+
+### Raising intents
+You can raise an intent by using the `RaiseIntent` function and return its result by using the `GetResult` of the returned `IIntentResolution`:
+```csharp
+var intentResolution = await desktopAgent.RaiseIntent("ViewChart", context, appIdentifier);
+var intentResult = await intentResolution.GetResult();
+```
+
+### Opening an app
+You can open an app by using the `Open` function:
+```csharp
+var appIdentifier = new AppIdentifier("your-app-id");
+var instrument = new Instrument();
+
+var appInstance = await desktopAgent.Open(appIdentifier, instrument);
+//The opened app should handle the context if it has registered a listener for that context type; if it does not register its context listener in time the open call will fail
+```
+
+### Creating Private Channel
+You can create a private channel by using the `CreatePrivateChannel` function:
+```csharp
+var privateChannel = await desktopAgent.CreatePrivateChannel("your-private-channel-id");
+var contextListenerHandler = privateChannel.OnAddContextListener((ctx) => {
+ Console.WriteLine($"Private channel context listener has been added for context: {ctx}");
+});
+
+var unsubscribeHandler = privateChannel.OnUnsubscribe((ctx) => {
+ Console.WriteLine($"Private channel context listener has been unsubscribed for context: {ctx}");
+});
+
+var disconnectHandler = privateChannel.OnDisconnect(() => {
+ Console.WriteLine("Private channel has been disconnected");
+});
+```
+
## Documentation
For more details, see the [ComposeUI documentation](https://morganstanley.github.io/ComposeUI/).
diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/EndToEndTests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/EndToEndTests.cs
new file mode 100644
index 000000000..7bda5af23
--- /dev/null
+++ b/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/EndToEndTests.cs
@@ -0,0 +1,559 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using MorganStanley.ComposeUI.Messaging.Client.WebSocket;
+using MorganStanley.ComposeUI.ModuleLoader;
+using Finos.Fdc3;
+using FluentAssertions;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions;
+using Finos.Fdc3.Context;
+using MorganStanley.ComposeUI.Fdc3.AppDirectory;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol;
+using DisplayMetadata = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.DisplayMetadata;
+using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier;
+using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.IntegrationTests.Helpers;
+
+namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.IntegrationTests;
+
+public class EndToEndTests : IAsyncLifetime
+{
+ private const string TestChannel = "fdc3.channel.1";
+
+ private readonly Uri _webSocketUri = new("ws://localhost:7098/ws");
+ private ServiceProvider _clientServices;
+ private IHost _host;
+ private IModuleLoader _moduleLoader;
+ private readonly object _runningAppsLock = new();
+ private readonly List _runningApps = [];
+ private IDisposable _runningAppsObserver;
+ private IDesktopAgent _desktopAgent;
+ public EndToEndTests()
+ {
+ var repoRoot = RootPathResolver.GetRepositoryRoot();
+ Environment.SetEnvironmentVariable(Consts.COMPOSEUI_MODULE_REPOSITORY_ENVIRONMENT_VARIABLE_NAME, repoRoot, EnvironmentVariableTarget.Process);
+ }
+
+ public async Task InitializeAsync()
+ {
+ Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), null);
+ Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), null);
+
+ // Create the backend side
+ IHostBuilder hostBuilder = new HostBuilder();
+ hostBuilder.ConfigureServices(
+ serviceCollection =>
+ {
+ serviceCollection.AddMessageRouterServer(
+ s => s.UseWebSockets(
+ opt =>
+ {
+ opt.RootPath = _webSocketUri.AbsolutePath;
+ opt.Port = _webSocketUri.Port;
+ }));
+
+ serviceCollection.AddTransient();
+
+ serviceCollection.AddMessageRouter(
+ mr => mr
+ .UseServer());
+
+ serviceCollection.AddFdc3AppDirectory(
+ _ => _.Source = new Uri(@$"file:\\{Directory.GetCurrentDirectory()}\testAppDirectory.json"));
+
+ serviceCollection.AddModuleLoader();
+
+ serviceCollection.AddFdc3DesktopAgent(
+ fdc3 =>
+ {
+ fdc3.Configure(builder =>
+ {
+ builder.ChannelId = TestChannel;
+ builder.IntentResultTimeout = TimeSpan.FromSeconds(3);
+ builder.ListenerRegistrationTimeout = TimeSpan.FromSeconds(3);
+ });
+ });
+
+ serviceCollection.AddMessageRouterMessagingAdapter();
+ });
+
+ _host = hostBuilder.Build();
+ await _host.StartAsync();
+
+ // Create a client acting in place of an application
+ _clientServices = new ServiceCollection()
+ .AddMessageRouter(
+ mr => mr.UseWebSocket(
+ new MessageRouterWebSocketOptions
+ {
+ Uri = _webSocketUri
+ }))
+ .AddMessageRouterMessagingAdapter()
+ .AddFdc3DesktopAgentClient()
+ .BuildServiceProvider();
+
+ _moduleLoader = _host.Services.GetRequiredService();
+
+ _runningAppsObserver = _moduleLoader.LifetimeEvents.Subscribe(
+ lifetimeEvent =>
+ {
+ lock (_runningAppsLock)
+ {
+ switch (lifetimeEvent.EventType)
+ {
+ case LifetimeEventType.Started:
+ _runningApps.Add(lifetimeEvent.Instance);
+ break;
+
+ case LifetimeEventType.Stopped:
+ _runningApps.Remove(lifetimeEvent.Instance);
+ break;
+ }
+ }
+ });
+
+ var instanceId = Guid.NewGuid().ToString();
+
+ var instance = await _moduleLoader.StartModule(new StartRequest("appId1", new Dictionary() { { "Fdc3InstanceId", instanceId } }));
+
+ Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), "appId1");
+ Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), instanceId);
+
+ _desktopAgent = _clientServices.GetRequiredService();
+ }
+
+ public async Task DisposeAsync()
+ {
+ List runningApps;
+ _runningAppsObserver?.Dispose();
+ lock (_runningAppsLock)
+ {
+ runningApps = _runningApps.Reverse().ToList();
+ }
+
+ foreach (var instance in runningApps)
+ {
+ await _moduleLoader.StopModule(new StopRequest(instance.InstanceId));
+ }
+
+ await _clientServices.DisposeAsync();
+ await _host.StopAsync();
+ _host.Dispose();
+
+ Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), null);
+ Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), null);
+ }
+
+ [Fact]
+ public async Task GetAppMetadata_throws_error_as_error_response_received()
+ {
+ var action = async () => await _desktopAgent.GetAppMetadata(new AppIdentifier { AppId = "nonExistingApp" });
+ await action.Should()
+ .ThrowAsync()
+ .WithMessage("*nonExistingApp*");
+ }
+
+ [Fact]
+ public async Task GetAppMetadata_returns_AppMetadata()
+ {
+ var result = await _desktopAgent.GetAppMetadata(app: new AppIdentifier { AppId = "appId1" });
+ result.Should().BeEquivalentTo(TestAppDirectoryData.DefaultApp1);
+ }
+
+ [Fact]
+ public async Task GetInfo_returns_ImplementationMetadata()
+ {
+ var result = await _desktopAgent.GetInfo();
+
+ result.AppMetadata.Should().NotBeNull();
+ result.AppMetadata.AppId.Should().Be("appId1");
+ }
+
+ [Fact]
+ public async Task GetInfo_throws_error_as_instance_id_not_found()
+ {
+ Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), null);
+ Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), null);
+
+ Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), "nonExistentAppId");
+ Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), Guid.NewGuid().ToString());
+
+ var desktopAgent = _clientServices.GetRequiredService();
+
+ var action = async () => await desktopAgent.GetInfo();
+
+ await action.Should()
+ .ThrowAsync()
+ .WithMessage($"*{Fdc3DesktopAgentErrors.MissingId}*");
+ }
+
+ [Fact]
+ public async Task JoinUserChannel_joins_to_a_user_channel()
+ {
+ await _desktopAgent.JoinUserChannel("fdc3.channel.1");
+ var currentChannel = await _desktopAgent.GetCurrentChannel();
+
+ currentChannel.Id.Should().Be("fdc3.channel.1");
+ }
+
+ [Fact]
+ public async Task JoinUserChannel_joins_to_a_user_channel_and_registers_already_added_top_level_context_listeners()
+ {
+ var resultContexts = new List();
+
+ var module = await _moduleLoader.StartModule(new StartRequest("appId1-native", new Dictionary() { { "Fdc3InstanceId", Guid.NewGuid().ToString() } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. The app broadcasts an instrument context after it joined to the fdc3.channel.1.
+ //We need to wait somehow for the module to finish up the broadcast
+ await Task.Delay(2000);
+
+ var listener1 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); });
+ var listener2 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); });
+
+ await _desktopAgent.JoinUserChannel("fdc3.channel.1");
+ var currentChannel = await _desktopAgent.GetCurrentChannel();
+
+ currentChannel.Id.Should().Be("fdc3.channel.1");
+
+ resultContexts.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public async Task LeaveCurrentChannel_leaves_the_joined_channel()
+ {
+ await _desktopAgent.JoinUserChannel("fdc3.channel.1");
+ var currentChannel = await _desktopAgent.GetCurrentChannel();
+
+ currentChannel.Id.Should().Be("fdc3.channel.1");
+
+ await _desktopAgent.LeaveCurrentChannel();
+ currentChannel = await _desktopAgent.GetCurrentChannel();
+
+ currentChannel.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task AddContextListener_can_be_registered_multiple_times()
+ {
+ var listener1 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { });
+ var listener2 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { });
+
+ listener1.Should().NotBeNull();
+ listener2.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task AddContextListener_can_be_registered_but_they_do_not_receive_anything_until_they_joined_to_a_channel()
+ {
+ var resultContexts = new List();
+
+ var module = await _moduleLoader.StartModule(new StartRequest("appId1-native", new Dictionary() { { "Fdc3InstanceId", Guid.NewGuid().ToString() } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. The app broadcasts an instrument context after it joined to the fdc3.channel.1.
+ var listener1 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); });
+ var listener2 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); });
+
+ listener1.Should().NotBeNull();
+ listener2.Should().NotBeNull();
+ resultContexts.Should().HaveCount(0);
+ }
+
+ [Fact]
+ public async Task Broadcast_is_not_retrieved_to_the_same_instance()
+ {
+ var resultContexts = new List();
+
+ var module = await _moduleLoader.StartModule(new StartRequest("appId1-native", new Dictionary() { { "Fdc3InstanceId", Guid.NewGuid().ToString() } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. The app broadcasts an instrument context after it joined to the fdc3.channel.1.
+ //We need to wait somehow for the module to finish up the broadcast
+ await Task.Delay(2000);
+
+ await _desktopAgent.JoinUserChannel("fdc3.channel.1");
+
+ var listener1 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); });
+ var listener2 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); });
+ var currentChannel = await _desktopAgent.GetCurrentChannel();
+
+ await _desktopAgent.Broadcast(new Instrument(new InstrumentID { Ticker = $"test-instrument-{Guid.NewGuid().ToString()}" }, "test-name"));
+
+ currentChannel.Id.Should().Be("fdc3.channel.1");
+ resultContexts.Should().HaveCount(2); //not 4
+ }
+
+ [Fact]
+ public async Task Broadcast_fails_on_not_joined_to_channel()
+ {
+ var action = async () => await _desktopAgent.Broadcast(new Instrument(new InstrumentID { Ticker = $"test-instrument-{Guid.NewGuid().ToString()}" }, "test-name"));
+
+ await action.Should()
+ .ThrowAsync()
+ .WithMessage($"*No current channel to broadcast the context to.*");
+ }
+
+ [Fact]
+ public async Task GetUserChannels_returns_UserChannels()
+ {
+ var result = await _desktopAgent.GetUserChannels();
+ result.Should().NotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task GetOrCreateChannel_returns_channel()
+ {
+ var result = await _desktopAgent.GetOrCreateChannel("app-channel-1");
+
+ result.Id.Should().Be("app-channel-1");
+ result.Type.Should().Be(ChannelType.App);
+ }
+
+ [Fact]
+ public async Task GetOrCreateChannel_throws_error_as_error_response_received()
+ {
+ var action = async () => await _desktopAgent.GetOrCreateChannel(string.Empty);
+ await action.Should()
+ .ThrowAsync()
+ .WithMessage($"*{ChannelError.CreationFailed}*");
+ }
+
+ [Fact]
+ public async Task GetOrCreateChannel_creates_channel_and_client_able_to_broadcast_and_receive_context()
+ {
+ var resultContexts = new List();
+
+ var appChannel = await _desktopAgent.GetOrCreateChannel("app-channel-1");
+
+ var listener = await appChannel.AddContextListener("fdc3.instrument", (context, metadata) =>
+ {
+ resultContexts.Add(context);
+ });
+
+ var module = await _moduleLoader.StartModule(new StartRequest("appId1-native", new Dictionary() { { "Fdc3InstanceId", Guid.NewGuid().ToString() } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. For test only
+ //We need to wait somehow for the module to finish up the broadcast
+ await Task.Delay(2000);
+
+ await appChannel.Broadcast(new Instrument(new InstrumentID { Ticker = $"test-instrument-1" }, "test-name1"));
+
+ appChannel.Id.Should().Be("app-channel-1");
+
+ resultContexts.Should().BeEquivalentTo(new List() { new Instrument(new InstrumentID { Ticker = $"test-instrument-2" }, "test-name2") });
+ }
+
+ [Fact]
+ public async Task FindIntent_returns_AppIntent()
+ {
+ var result = await _desktopAgent.FindIntent("intent1");
+
+ result.Apps.Should().HaveCount(2);
+
+ result.Apps.First().AppId.Should().Be("appId1");
+ result.Apps.First().InstanceId.Should().BeNull();
+
+ //We are starting the app1 instance for each tests, so the second app in the list should have the instanceId defined
+ result.Apps.ElementAt(1).AppId.Should().Be("appId1");
+ result.Apps.ElementAt(1).InstanceId.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task FindIntent_throws_when_intent_not_found()
+ {
+ var act = async () => await _desktopAgent.FindIntent("notExistent");
+
+ await act.Should().ThrowAsync()
+ .WithMessage($"*{ResolveError.NoAppsFound}*");
+ }
+
+ [Fact]
+ public async Task FindInstances_returns_AppIdentifier()
+ {
+ var result = await _desktopAgent.FindInstances(new AppIdentifier { AppId = "appId1" });
+
+ result.Should().HaveCount(1);
+ }
+
+ [Fact]
+ public async Task FindInstances_throws_error_when_app_not_found()
+ {
+ var act = async () => await _desktopAgent.FindInstances(new AppIdentifier { AppId = "notExistent" });
+
+ await act.Should().ThrowAsync()
+ .WithMessage($"*{ResolveError.NoAppsFound}*");
+ }
+
+ [Fact]
+ public async Task FindIntentsByContext_returns_AppIdentifier()
+ {
+ var context = new Nothing();
+
+ var result = await _desktopAgent.FindIntentsByContext(context);
+ result.Should().NotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task FindIntentsByContext_throws_when_not_app_can_handle_the_context()
+ {
+ var context = new Currency(new CurrencyID { CURRENCY_ISOCODE = "USD" });
+
+ var act = async () => await _desktopAgent.FindIntentsByContext(context);
+
+ await act.Should().ThrowAsync()
+ .WithMessage($"*{ResolveError.NoAppsFound}*");
+ }
+
+ [Fact]
+ public async Task RaiseIntentForContext_raises_intent_and_got_resolved_as_channel()
+ {
+ var fdc3InstanceId = Guid.NewGuid().ToString();
+ var appId = "appId1-native";
+ var module = await _moduleLoader.StartModule(new StartRequest(appId, new Dictionary() { { "Fdc3InstanceId", fdc3InstanceId } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. For test only
+ //We need to wait somehow for the module to finish up the listener registration
+ await Task.Delay(2000);
+
+ var result = await _desktopAgent.RaiseIntentForContext(new Instrument(new InstrumentID { Ticker = "AAPL" }, "Apple Inc."), new AppIdentifier { AppId = appId, InstanceId = fdc3InstanceId });
+ result.Should().NotBeNull();
+
+ var channel = await result.GetResult();
+
+ channel.Should().BeAssignableTo();
+ }
+
+ [Fact]
+ public async Task RaiseIntentForContext_raises_intent_throws_when_no_app_could_handle_the_request()
+ {
+ var act = async () => await _desktopAgent.RaiseIntentForContext(new Chart(new Instrument[] { new Instrument() }));
+
+ await act.Should().ThrowAsync()
+ .WithMessage($"*{ResolveError.NoAppsFound}*");
+ }
+
+ [Fact]
+ public async Task RaiseIntentForContext_raises_intent_throws_when_listener_not_registered()
+ {
+ var fdc3InstanceId = Guid.NewGuid().ToString();
+ var appId = "appId1-native";
+ var module = await _moduleLoader.StartModule(new StartRequest(appId, new Dictionary() { { "Fdc3InstanceId", fdc3InstanceId } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. For test only
+ //We need to wait somehow for the module to finish up the listener registration
+ await Task.Delay(2000);
+
+ var act = async () => await _desktopAgent.RaiseIntentForContext(new Country(), new AppIdentifier { AppId = appId, InstanceId = fdc3InstanceId });
+
+ await act.Should().ThrowAsync()
+ .WithMessage($"*{ResolveError.IntentDeliveryFailed}*");
+ }
+
+ [Fact]
+ public async Task AddIntentListener_able_to_handle_raised_intent()
+ {
+ var handledContexts = new List();
+
+ var listener = await _desktopAgent.AddIntentListener