From b9f4198d8ecdb88de06392bfaa39e7bfe05538d8 Mon Sep 17 00:00:00 2001 From: lilla28 <36889371+lilla28@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:49:33 +0100 Subject: [PATCH 1/5] feat(fdc3) - Modified Fdc3 native client to allow argumetns from ctor; improved documentation, added client factory to allow shell specific client creation; Refactor FDC3 startup properties and channel selector; Introduce Fdc3StartupProperties class, replacing Fdc3Properties for startup configuration and updating all references. Refactor IDesktopAgentClientFactory and IStartupModuleHandler to use the new property structure, centralizing FDC3 startup logic. Move ModuleChannelSelector to Shared library for broader reuse. Expand and clarify README with detailed guidance on channel selector integration, UI responsibilities, and app channel usage. Improve ChannelHandler to trigger channel selector UI updates after channel joins. Refactor ContextListener unsubscribe logic for robust async error handling. Remove obsolete NativeInProcess module type and update project dependencies. Overall, enhance maintainability, clarity, and container UI integration. --- src/fdc3/dotnet/DesktopAgent.Client/README.md | 68 ---------- .../IDesktopAgentClientFactory.cs | 41 ++++++ .../Infrastructure/DesktopAgentClient.cs | 128 +++++++++++++----- .../Infrastructure/IChannelHandler.cs | 3 + .../Infrastructure/Internal/ChannelHandler.cs | 12 +- .../Internal/ContextListener.cs | 66 +++++---- .../README.md | 127 +++++++++++++++-- .../DesktopAgentClient.Tests.cs | 2 +- .../Internal/ContextListener.Tests.cs | 35 ----- .../Exceptions/ThrowHelper.cs | 3 + .../Fdc3StartupProperties.cs | 5 + .../ModuleChannelSelector.cs | 92 +++++++++++++ ....ComposeUI.Fdc3.DesktopAgent.Shared.csproj | 13 +- .../Extensions/StartupContextExtensions.cs | 78 +++++++++++ .../Fdc3DesktopAgentService.cs | 1 - .../Fdc3ShutdownAction.cs | 45 +++--- .../Fdc3StartupAction.cs | 35 ++--- .../IChannelSelector.cs | 28 ---- ...uleHandler.cs => IStartupModuleHandler.cs} | 12 +- .../Internal/NativeStartupModuleHandler.cs | 16 +-- .../Internal/WebStartupModuleHandler.cs | 17 +-- .../ModuleChannelSelector.cs | 72 +--------- .../Fdc3DesktopAgentServiceTestsBase.cs | 2 - .../Fdc3DesktopAgentMessagingServiceTests.cs | 3 - 24 files changed, 542 insertions(+), 362 deletions(-) delete mode 100644 src/fdc3/dotnet/DesktopAgent.Client/README.md create mode 100644 src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/IDesktopAgentClientFactory.cs create mode 100644 src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/ModuleChannelSelector.cs create mode 100644 src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Extensions/StartupContextExtensions.cs delete mode 100644 src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/IChannelSelector.cs rename src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/{StartupModuleHandler.cs => IStartupModuleHandler.cs} (62%) diff --git a/src/fdc3/dotnet/DesktopAgent.Client/README.md b/src/fdc3/dotnet/DesktopAgent.Client/README.md deleted file mode 100644 index 588cc4115..000000000 --- a/src/fdc3/dotnet/DesktopAgent.Client/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client -MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client is a .NET library (.NET Standard 2.0) that provides a client implementation of the FDC3 Desktop Agent API for ComposeUI applications. -It enables .NET applications to interact with FDC3-compliant desktop agents, facilitating interoperability, context sharing, and intent-based communication between financial desktop applications. - -## Features -- Implements the `IDesktopAgent` interface for FDC3 operations. -- Supports context and intent listeners, broadcasting, and channel management. -- Provides methods for opening applications, raising intents, and retrieving app metadata. -- Integrates with ComposeUI messaging abstraction (`IMessaging`), so it doesn't rely on any actual messaging implementation. -- Extensible logging support via `ILogger`. - -## Installation -Add a reference to the NuGet package (if available) or include the project in your solution. - -## Usage -Register the client in your dependency injection container: -```csharp -services.AddFdc3DesktopAgentClient(); -``` - -Use the `IDesktopAgent` interface in your application: -```csharp -public class MyFdc3Service -{ - private readonly IDesktopAgent _desktopAgent; - - public MyFdc3Service(IDesktopAgent desktopAgent) - { - _desktopAgent = desktopAgent; - } - - public async Task GetAppMetadata(IAppIdentifier app) - { - await _desktopAgent.GetAppMetadata(app); - } -} -``` - -## API Overview -Key methods provided by IDesktopAgent: -- AddContextListener(string? contextType, ContextHandler handler) where T : IContext -- AddIntentListener(string intent, IntentHandler handler) where T : IContext -- Broadcast(IContext context) -- CreatePrivateChannel() -- FindInstances(IAppIdentifier app) -- FindIntent(string intent, IContext? context = null, string? resultType = null) -- FindIntentsByContext(IContext context, string? resultType = null) -- GetAppMetadata(IAppIdentifier app) -- GetCurrentChannel() -- GetInfo() -- GetOrCreateChannel(string channelId) -- GetUserChannels() -- JoinUserChannel(string channelId) -- LeaveCurrentChannel() -- Open(IAppIdentifier app, IContext? context = null) -- RaiseIntent(string intent, IContext context, IAppIdentifier? app = null) -- RaiseIntentForContext(IContext context, IAppIdentifier? app = null) - -## Dependencies -- Finos.Fdc3 -- Microsoft.Extensions.Logging.Abstractions -- MorganStanley.ComposeUI.Messaging.Abstractions - -## Contributing -Contributions are welcome! Please submit issues or pull requests via GitHub. - -## License -This library is licensed under the Apache License, Version 2.0. \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/IDesktopAgentClientFactory.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/IDesktopAgentClientFactory.cs new file mode 100644 index 000000000..3d462a866 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/IDesktopAgentClientFactory.cs @@ -0,0 +1,41 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using Finos.Fdc3; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client; + +/// +/// Shell specific factory which will be used by the Fdc3DesktopAgent to retrieve the IDesktopAgent implementation that will be used for Fdc3 integration. This is needed to abstract away how the DesktopAgentClient can be resolved. +/// +public interface IDesktopAgentClientFactory +{ + /// + /// Returns the IDesktopAgent implementation that will be used by the Fdc3DesktopAgent for Fdc3 integration. The implementation of this factory should handle the resolution of the IDesktopAgent, whether it's through a service locator, dependency injection, or any other method. This allows the DesktopAgentClient to remain decoupled from the specifics of how the IDesktopAgent is provided, enabling greater flexibility and testability. + /// + /// Unique identifier of the module + /// Callback which signals that the desktop agent client is ready for the module. + /// + public Task GetDesktopAgentAsync(string identifier, Action onReady); + + /// + /// Registers the Fdc3StartupProperties for in-process apps, which can be used to provide necessary information about the app to the DesktopAgentClient for proper Fdc3 integration. + /// This helps to identify the different native modules and provides ability to create the DesktopAgent client which can be used for in process apps to collaborate in the FDC3 ecosystem. + /// The implementation of this method should handle the storage and management of the Fdc3StartupProperties for in-process apps, ensuring that the DesktopAgentClient can access this information when needed for Fdc3 operations. + /// This is particularly important for scenarios where multiple in-process apps are running and need to be distinguished from each other for proper Fdc3 functionality. + /// + /// + public Task RegisterInProcessAppPropertiesAsync(Fdc3StartupProperties fdc3Properties); +} 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 50cbcf772..b16ab457e 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 @@ -32,8 +32,8 @@ public class DesktopAgentClient : IDesktopAgent, IAsyncDisposable private readonly IMessaging _messaging; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; - private readonly string _appId; - private readonly string _instanceId; + private string _appId; + private string _instanceId; private IChannelHandler _channelHandler; private IMetadataClient _metadataClient; private IIntentsClient _intentsClient; @@ -50,33 +50,77 @@ public class DesktopAgentClient : IDesktopAgent, IAsyncDisposable private readonly TaskCompletionSource _initializationTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// + /// The app and instance identifiers can be provided through environment variables or by setting the relative arguments in the ctor. + /// + /// + /// Fdc3App app id + /// Fdc3App insatnce id + /// public DesktopAgentClient( IMessaging messaging, + string? appId = null, + string? instanceId = null, + string? initialUserChannelId = null, + string? initialOpenAppContextId = null, + Action? onReady = null, ILoggerFactory? loggerFactory = null) { _messaging = messaging; _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; _logger = _loggerFactory.CreateLogger(); - _appId = Environment.GetEnvironmentVariable(nameof(AppIdentifier.AppId)) ?? throw ThrowHelper.MissingAppId(string.Empty); - _instanceId = Environment.GetEnvironmentVariable(nameof(AppIdentifier.InstanceId)) ?? throw ThrowHelper.MissingInstanceId(_appId, string.Empty); + _appId = Environment.GetEnvironmentVariable(nameof(AppIdentifier.AppId)); + _instanceId = Environment.GetEnvironmentVariable(nameof(AppIdentifier.InstanceId)); + + if (string.IsNullOrEmpty(_appId) || string.IsNullOrEmpty(_instanceId)) + { + if (string.IsNullOrEmpty(appId) || string.IsNullOrEmpty(instanceId)) + { + throw ThrowHelper.MissingAppIdentifier(appId, instanceId); + } + else + { + _appId = appId!; + _instanceId = instanceId!; + } + } + + var channelId = Environment.GetEnvironmentVariable(Fdc3StartupParameters.Fdc3ChannelId); + if (string.IsNullOrEmpty(channelId) + && !string.IsNullOrEmpty(initialUserChannelId)) + { + channelId = initialUserChannelId; + } + + var openedAppContextId = Environment.GetEnvironmentVariable(Fdc3StartupParameters.OpenedAppContextId); + if (string.IsNullOrEmpty(openedAppContextId) + && !string.IsNullOrEmpty(initialOpenAppContextId)) + { + openedAppContextId = initialOpenAppContextId; + } if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("AppID: {AppId}; InstanceId: {InstanceId} is registered for the FDC3 client app.", _appId, _instanceId); } + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Retrieved startup parameters for channelId: {ChannelId}; openedAppContextId: {OpenedAppContext}.", channelId ?? "null", openedAppContextId ?? "null"); + } + _metadataClient = new MetadataClient(_appId, _instanceId, _messaging, _loggerFactory.CreateLogger()); _openClient = new OpenClient(_instanceId, _messaging, this, _loggerFactory.CreateLogger()); - _ = Task.Run(() => InitializeAsync().ConfigureAwait(false)); + _ = Task.Run(() => InitializeAsync(channelId, openedAppContextId, onReady).ConfigureAwait(false)); } /// /// 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() + private async Task InitializeAsync(string? channelId, string? openedAppContextId, Action? onReady = null) { try { @@ -85,19 +129,6 @@ private async Task InitializeAsync() _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)) { await GetOpenedAppContextAsync(openedAppContextId!).ConfigureAwait(false); @@ -106,7 +137,7 @@ private async Task InitializeAsync() _channelHandler = new ChannelHandler(_messaging, _instanceId, this, _openedAppContext, _loggerFactory); _intentsClient = new IntentsClient(_messaging, _channelHandler, _instanceId, _loggerFactory); - await _channelHandler.ConfigureChannelSelectorAsync(CancellationToken.None).ConfigureAwait(false); + await _channelHandler.ConfigureChannelSelectorAsync().ConfigureAwait(false); _initializationTaskCompletionSource.SetResult(_instanceId); } @@ -114,6 +145,19 @@ private async Task InitializeAsync() { _initializationTaskCompletionSource.TrySetException(exception); } + finally + { + //The responsibility of triggering the channel selector using native client is the container's since the desktop agent client doesn't have the information about the UI. + //If the channelId is provided through startup parameters, the desktop agent client will join the channel directly without triggering the channel selector. + //In this case, we need to trigger the channel selector to update the UI in order to reflect the current channel that the app has joined. + //Because it can cause deadlock to trigger the channel selector within the lock in the JoinUserChannelAsync, we trigger it here after the initialization is completed and the channel is joined. + if (!string.IsNullOrEmpty(channelId)) + { + await JoinUserChannelAsync(channelId!).ConfigureAwait(false); + } + + onReady?.Invoke(this); + } } /// @@ -334,7 +378,12 @@ public async Task> GetUserChannels() public async Task JoinUserChannel(string channelId) { await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + await JoinUserChannelAsync(channelId).ConfigureAwait(false); + await _channelHandler.TriggerChannelSelector(_currentChannel!).ConfigureAwait(false); + } + private async Task JoinUserChannelAsync(string channelId) + { try { await _currentChannelLock.WaitAsync().ConfigureAwait(false); @@ -342,7 +391,9 @@ public async Task JoinUserChannel(string channelId) if (_currentChannel != null) { _logger.LogInformation("Leaving current channel: {CurrentChannelId}...", _currentChannel.Id); - await LeaveCurrentChannel().ConfigureAwait(false); + + //If we call directly the LeaveCurrentChannel then we might encounter a deadlock + LeaveCurrentChannelWithoutLock(); } var channel = await _channelHandler.JoinUserChannelAsync(channelId).ConfigureAwait(false); @@ -376,27 +427,32 @@ public async Task LeaveCurrentChannel() try { await _currentChannelLock.WaitAsync().ConfigureAwait(false); - var contextListeners = _contextListeners.Reverse(); - - //The context listeners, that have been added through the `fdc3.addContextListener()` should unsubscribe, but the context listeners should remain registered to the DesktopAgentClient instance. - foreach (var contextListener in contextListeners) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Unsubscribing context listener from channel: {CurrentChannelId}...", _currentChannel?.Id); - } + LeaveCurrentChannelWithoutLock(); + } + finally + { + _currentChannelLock.Release(); + } + } - contextListener.Key.Unsubscribe(); - } + private void LeaveCurrentChannelWithoutLock() + { + var contextListeners = _contextListeners.Reverse(); - if (_currentChannel != null) + //The context listeners, that have been added through the `fdc3.addContextListener()` should unsubscribe, but the context listeners should remain registered to the DesktopAgentClient instance. + foreach (var contextListener in contextListeners) + { + if (_logger.IsEnabled(LogLevel.Debug)) { - _currentChannel = null; + _logger.LogDebug("Unsubscribing context listener from channel: {CurrentChannelId}...", _currentChannel?.Id); } + + contextListener.Key.Unsubscribe(); } - finally + + if (_currentChannel != null) { - _currentChannelLock.Release(); + _currentChannel = null; } } diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelHandler.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelHandler.cs index 3862ce12d..a71a4bdf9 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelHandler.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelHandler.cs @@ -44,6 +44,9 @@ public ValueTask> CreateContextListenerAsync( /// A representing the asynchronous operation. public ValueTask JoinUserChannelAsync(string channelId); + + public ValueTask TriggerChannelSelector(IChannel channel); + /// /// Retrieves all available user channels. /// diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelHandler.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelHandler.cs index 32a57e729..dad487ae3 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelHandler.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelHandler.cs @@ -23,7 +23,6 @@ using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; using MorganStanley.ComposeUI.Messaging.Abstractions; -using MorganStanley.ComposeUI.Messaging.Abstractions.Exceptions; namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal; @@ -213,18 +212,21 @@ public async ValueTask JoinUserChannelAsync(string channelId) displayMetadata: response.DisplayMetadata, loggerFactory: _loggerFactory); + return channel; + } + + public async ValueTask TriggerChannelSelector(IChannel channel) + { try { - var result = await _messaging.InvokeServiceAsync(Fdc3Topic.ChannelSelectorFromAPI(_instanceId), channelId).ConfigureAwait(false); + var result = await _messaging.InvokeServiceAsync(Fdc3Topic.ChannelSelectorFromAPI(_instanceId), channel.Id).ConfigureAwait(false); _logger.LogDebug("Triggered channel selector from API for module: {InstanceId}, with {ChannelId}, and backend returned result: {Result}", _instanceId, channel.Id, result); } catch (Exception exception) { - _logger.LogWarning(exception, "Failed to trigger channel selector from API for module: {InstanceId}, with {ChannelId}.", channelId, _instanceId); + _logger.LogWarning(exception, "Failed to trigger channel selector from API for module: {InstanceId}, with {ChannelId}.", channel.Id, _instanceId); } - - return channel; } public async ValueTask FindChannelAsync(string channelId, ChannelType channelType) 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 e159b5c32..4cc720570 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 @@ -90,14 +90,21 @@ public void Unsubscribe() return; } - UnregisterContextListenerAsync() - .AsTask() - .ConfigureAwait(false) - .GetAwaiter() - .GetResult(); + async Task UnregisterAndCatchAsync() + { + try + { + await UnregisterContextListenerAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during UnregisterContextListenerAsync."); + } + } + + _ = UnregisterAndCatchAsync(); _subscription?.DisposeAsync() - .AsTask() .ConfigureAwait(false) .GetAwaiter() .GetResult(); @@ -109,6 +116,10 @@ public void Unsubscribe() _unsubscribeCallback(this); } } + catch (Exception exception) + { + throw; + } finally { _subscriptionLock.Release(); @@ -280,31 +291,38 @@ private async ValueTask RegisterContextListenerAsync( private async ValueTask UnregisterContextListenerAsync() { - var request = new RemoveContextListenerRequest + try { - ListenerId = _contextListenerId, - Fdc3InstanceId = _instanceId, - ContextType = _contextType - }; + var request = new RemoveContextListenerRequest + { + ListenerId = _contextListenerId, + Fdc3InstanceId = _instanceId, + ContextType = _contextType + }; - var response = await _messaging.InvokeJsonServiceAsync(Fdc3Topic.RemoveContextListener, request, _jsonSerializerOptions); + var response = await _messaging.InvokeJsonServiceAsync(Fdc3Topic.RemoveContextListener, request, _jsonSerializerOptions); - if (response == null) - { - throw ThrowHelper.MissingResponse(); - } + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } - if (!string.IsNullOrEmpty(response.Error)) - { - throw ThrowHelper.ErrorResponseReceived(response.Error); - } + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } - if (!response.Success) + if (!response.Success) + { + throw ThrowHelper.UnsuccessfulSubscriptionUnRegistration(request); + } + + _contextListenerId = null; + } + catch (Exception) { - throw ThrowHelper.UnsuccessfulSubscriptionUnRegistration(request); + throw; } - - _contextListenerId = null; } internal void SetUnsubscribeCallback(Action> unsubscribeCallback) 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 c88b419fc..34c6b0e6d 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 @@ -92,7 +92,119 @@ Key methods provided by IDesktopAgent: - Microsoft.Extensions.Logging.Abstractions - MorganStanley.ComposeUI.Messaging.Abstractions -## Usage +## IDesktopAgentClientFactory +The `IDesktopAgentClientFactory` interface provides a shell-specific factory for resolving `IDesktopAgent` instances. This abstraction allows the Desktop Agent client to remain decoupled from the specifics of how the `IDesktopAgent` is provided, enabling greater flexibility and testability. + +> **Important:** The `IDesktopAgentClientFactory` implementation should be registered as a singleton in your dependency injection container to ensure proper FDC3 integration. A single instance is required to maintain consistent state and coordination across all modules and in-process applications. + +### Methods +- **GetDesktopAgentAsync(string identifier, Action\ onReady)**: Returns the `IDesktopAgent` implementation for a given module identifier. The `onReady` callback signals when the desktop agent client is ready. +- **RegisterInProcessAppPropertiesAsync(Fdc3StartupProperties fdc3Properties)**: Registers FDC3 properties for in-process apps, enabling them to participate in the FDC3 ecosystem. This is particularly important for distinguishing multiple in-process apps running within the same shell. + +### Example +```csharp +public class MyShellIntegration +{ + private readonly IDesktopAgentClientFactory _factory; + + public MyShellIntegration(IDesktopAgentClientFactory factory) + { + _factory = factory; + } + + public async Task InitializeModule(string moduleId) + { + await _factory.GetDesktopAgentAsync(moduleId, desktopAgent => + { + // Desktop agent is ready for use + }); + } +} +``` + +## Fdc3StartupProperties +The `Fdc3StartupProperties` class represents the FDC3 properties for an application, providing necessary identification and context information for proper FDC3 integration. + +### Properties +| Property | Description | +|----------|-------------| +| `AppId` | The unique application identifier as defined in the FDC3 App Directory. | +| `InstanceId` | The unique instance identifier for this running instance of the application. | +| `ChannelId` | The channel identifier that the application is currently joined to. | +| `OpenedAppContextId` | The context identifier used when opening the application, typically containing context data passed during Open or RaiseIntent operations. | + +### Example +```csharp +var properties = new Fdc3StartupProperties +{ + AppId = "my-app", + InstanceId = Guid.NewGuid().ToString(), + ChannelId = "fdc3.channel.1", + OpenedAppContextId = "context-123" +}; + +await _desktopAgentClientFactory.RegisterInProcessAppPropertiesAsync(properties); +``` + +### Channel Selector Behavior for Native Clients +Native clients must handle channel selection UI independently since the Desktop Agent Client does not have knowledge of the container's UI. The implementation uses a messaging-based approach where containers can register their own channel selector logic. + +#### Registering a Channel Selector +Each container instance should register its own channel selector logic by subscribing to the `Fdc3Topic.ChannelSelectorFromAPI({instanceId})` topic. This service is invoked whenever `JoinUserChannel` is called from the API, allowing the UI to update and reflect the currently joined user channel. + +#### Initial User Channel Behavior +When the Desktop Agent Client is initialized with an initial user channel (via `Fdc3StartupProperties.ChannelId` or environment variable), the implementation joins the channel **without triggering the registered channel selector service**. This is intentional because: +- The container already knows the initial channel value (it provided it during startup) +- This avoids unnecessary round-trips and potential UI flickering during initialization +- The container can immediately display the correct initial state without waiting for a callback + +The container is responsible for displaying the initial user channel state in its UI channel selector component. + +#### JoinUserChannel Triggers the Channel Selector +After initialization, whenever `JoinUserChannel(channelId)` is called (either by the application or via the UI), the implementation: +1. Joins the specified user channel +2. Triggers the registered channel selector service at `Fdc3Topic.ChannelSelectorFromAPI({instanceId})` with the channel ID + +This ensures the UI can update to reflect the new channel membership. + +#### Channel Selection from UI +If you provide a UI channel selector component, it can trigger channel joins by invoking the `Fdc3Topic.ChannelSelectorFromUI({instanceId})` service with a `JoinUserChannelRequest` payload. The Desktop Agent Client registers this service during initialization and will call `JoinUserChannel` internally when invoked. + +```csharp +// Example: Container registering to receive channel selector updates +await messaging.RegisterServiceAsync( + Fdc3Topic.ChannelSelectorFromAPI(instanceId), + (channelId) => + { + // Update your UI channel selector to display the joined channel + UpdateChannelSelectorUI(channelId); + return ValueTask.FromResult(null); + }); + +// Example: UI triggering a channel join +var request = new JoinUserChannelRequest { ChannelId = "fdc3.channel.1" }; +await messaging.InvokeServiceAsync( + Fdc3Topic.ChannelSelectorFromUI(instanceId), + JsonSerializer.Serialize(request)); +``` + +> **Note:** The initial user channel provided during startup will not trigger the channel selector callback. The container must handle displaying the initial channel state itself based on the `Fdc3StartupProperties.ChannelId` value it provided. + +### 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); +``` + + +## Default Usage of IDesktopAgent ### Broadcasting Context An app is not able to broadcast any message unless it has joined a channel. After joining a channel, you can broadcast context to all listeners in that channel. ```csharp @@ -154,19 +266,6 @@ 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 diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/DesktopAgentClient.Tests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/DesktopAgentClient.Tests.cs index f4dd10b7a..d190aa14f 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/DesktopAgentClient.Tests.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/DesktopAgentClient.Tests.cs @@ -231,7 +231,6 @@ public async Task JoinUserChannel_handles_last_context_for_all_the_registered_to It.IsAny(), It.IsAny())) .Returns(new ValueTask(JsonSerializer.Serialize(new JoinUserChannelResponse { Success = true, DisplayMetadata = new DisplayMetadata() { Name = "test-channelId" } }, _jsonSerializerOptions))) - .Returns(new ValueTask("test-channelId")) .Returns(new ValueTask(JsonSerializer.Serialize(new AddContextListenerResponse { Success = true, Id = Guid.NewGuid().ToString() }, _jsonSerializerOptions))) .Returns(new ValueTask(JsonSerializer.Serialize(new Instrument(new InstrumentID { Ticker = "test-instrument" }, "test-name"), _jsonSerializerOptions))) //GetCurrentContext response after joining to the channels when iterating through the context listeners .Returns(new ValueTask(JsonSerializer.Serialize(new AddContextListenerResponse { Success = true, Id = Guid.NewGuid().ToString() }, _jsonSerializerOptions))) @@ -333,6 +332,7 @@ public async Task AddContextListener_handles_last_context_on_the_channel() .Returns(new ValueTask(JsonSerializer.Serialize(new Context("test-type"), _jsonSerializerOptions))); var desktopAgent = new DesktopAgentClient(messagingMock.Object); + await desktopAgent.JoinUserChannel("test-channelId"); var listener = await desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContextListenerInvocations++; }); diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/ContextListener.Tests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/ContextListener.Tests.cs index 35485282f..74b4e0ad1 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/ContextListener.Tests.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/ContextListener.Tests.cs @@ -110,41 +110,6 @@ public async Task Unsubscribe_unregisters_and_disposes_when_subscribed_on_the_co Times.Exactly(2)); } - [Fact] - public async Task Unsubscribe_handles_exception_during_unregister_on_the_context_listener() - { - var response = new AddContextListenerResponse - { - Error = null, - Success = true, - Id = "listenerId" - }; - - var disposableMock = new Mock(); - disposableMock.Setup(d => d.DisposeAsync()).Returns(ValueTask.CompletedTask); - - _messagingMock - .Setup(m => m.SubscribeAsync( - It.IsAny(), - It.IsAny(), - It.IsAny())) - .ReturnsAsync(disposableMock.Object); - - _messagingMock - .SetupSequence( - m => m.InvokeServiceAsync( - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(new ValueTask(JsonSerializer.Serialize(response, _jsonSerializerOptions))) - .ThrowsAsync(new InvalidOperationException("Unregister error")); - - await _listener.SubscribeAsync("channelId", ChannelType.App); - - var act = () => _listener.Unsubscribe(); - act.Should().Throw().WithMessage("Unregister error"); - } - [Fact] public async Task Unsubscribe_handles_exception_during_dispose_on_the_context_listener() { diff --git a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Exceptions/ThrowHelper.cs b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Exceptions/ThrowHelper.cs index 1b94dbe2b..286270464 100644 --- a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Exceptions/ThrowHelper.cs +++ b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Exceptions/ThrowHelper.cs @@ -130,4 +130,7 @@ internal static Fdc3DesktopAgentException PrivateChannelJoiningFailed(string cha internal static Fdc3DesktopAgentException MissingInstanceId(string methodName) => new($"Fdc3InstanceId was missing before executing: {methodName}."); + + internal static Fdc3DesktopAgentException MissingAppIdentifier(string? appId, string? fdc3InstanceId) => + new($"Fdc3InstanceId or the AppId were missing to initialize the DesktopAgentClient; AppId: {appId}, InstanceId: {fdc3InstanceId}."); } \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Fdc3StartupProperties.cs b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Fdc3StartupProperties.cs index c9f8040a2..7fc0dac51 100644 --- a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Fdc3StartupProperties.cs +++ b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Fdc3StartupProperties.cs @@ -19,6 +19,11 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; /// public class Fdc3StartupProperties { + /// + /// Id of the FDC3 app. + /// + public string AppId { get; init; } + /// /// Fdc3 DesktopAgent's specific identifier for the created application instance. /// diff --git a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/ModuleChannelSelector.cs b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/ModuleChannelSelector.cs new file mode 100644 index 000000000..a0023e4ae --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/ModuleChannelSelector.cs @@ -0,0 +1,92 @@ +/* + * 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 Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; +using MorganStanley.ComposeUI.Messaging.Abstractions; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; + +/// +/// Implementation of that uses messaging to communicate channel selection. +/// This class can be used by both server-side (Desktop Agent) and client-side (native containers) to: +/// - Register handlers to receive channel join notifications from API calls +/// - Invoke channel joins from UI components +/// +public class ModuleChannelSelector : IModuleChannelSelector +{ + private readonly ILogger _logger; + private readonly IMessaging _messaging; + private IAsyncDisposable? _handler; + private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization; + + public ModuleChannelSelector( + IMessaging messaging, + ILogger? logger = null) + { + _messaging = messaging; + _logger = logger ?? NullLogger.Instance; + } + + /// + public async ValueTask RegisterChannelSelectorHandlerInitiatedFromClientsAsync( + string fdc3InstanceId, + Action onChannelJoined, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(fdc3InstanceId)) + { + throw ThrowHelper.MissingInstanceId(nameof(RegisterChannelSelectorHandlerInitiatedFromClientsAsync)); + } + + _handler = await _messaging.RegisterServiceAsync( + Fdc3Topic.ChannelSelectorFromAPI(fdc3InstanceId), + (channelId) => + { + _logger.LogDebug("Request for instance: {InstanceId} was received with content: {ChannelId}", fdc3InstanceId, channelId); + + onChannelJoined(channelId); + + return new ValueTask(channelId); + }, + cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask InvokeJoinUserChannelFromUIAsync(string fdc3InstanceId, string channelId, CancellationToken cancellationToken = default) + { + var request = new JoinUserChannelRequest + { + InstanceId = fdc3InstanceId, + ChannelId = channelId + }; + + var result = await _messaging.InvokeServiceAsync(Fdc3Topic.ChannelSelectorFromUI(fdc3InstanceId), JsonSerializer.Serialize(request, _jsonSerializerOptions), cancellationToken).ConfigureAwait(false); + return result; + } + + /// + public ValueTask DisposeAsync() + { + if (_handler != null) + { + return _handler.DisposeAsync(); + } + + return new ValueTask(); + } +} diff --git a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.csproj b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.csproj index 89c72ea2c..4c321b56f 100644 --- a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.csproj +++ b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.csproj @@ -14,13 +14,18 @@ + - - - - + + + + + + + + \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Extensions/StartupContextExtensions.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Extensions/StartupContextExtensions.cs new file mode 100644 index 000000000..f6e438fc9 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Extensions/StartupContextExtensions.cs @@ -0,0 +1,78 @@ +// 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.AppDirectory; +using Microsoft.Extensions.Logging; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; +using MorganStanley.ComposeUI.ModuleLoader; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Extensions; + +/// +/// Helper class to retrieve FDC3 related properties from the startup context, app directory and user channel set, and to store them in the startup context for later use by specific module handlers. +/// +public static class StartupContextExtensions +{ + /// + /// Extracts FDC3 related properties such as AppId, InstanceId, ChannelId and OpenedAppContextId from the startup context, app directory and user channel set. + /// It also stores the retrieved properties in the startup context for later use by specific module handlers. + /// + /// + /// + /// + /// + /// + public static async Task GetFdc3Properties( + this StartupContext startupContext, + IAppDirectory appDirectory, + IUserChannelSetReader userChannelSetReader, + ILogger? logger = null) + { + try + { + var appId = (await appDirectory.GetApp(startupContext.StartRequest.ModuleId).ConfigureAwait(false)).AppId; + var userChannelSet = await userChannelSetReader.GetUserChannelSet().ConfigureAwait(false); + + var fdc3InstanceId = startupContext + .StartRequest + .Parameters + .FirstOrDefault(parameter => parameter.Key == Fdc3StartupParameters.Fdc3InstanceId).Value + ?? Guid.NewGuid().ToString(); + + var channelId = startupContext + .StartRequest + .Parameters + .FirstOrDefault(parameter => parameter.Key == Fdc3StartupParameters.Fdc3ChannelId).Value + ?? userChannelSet.FirstOrDefault().Key; + + var openedAppContextId = startupContext + .StartRequest + .Parameters + .FirstOrDefault(x => x.Key == Fdc3StartupParameters.OpenedAppContextId).Value; + + var fdc3StartupProperties = new Fdc3StartupProperties { InstanceId = fdc3InstanceId, ChannelId = channelId, OpenedAppContextId = openedAppContextId }; + fdc3InstanceId = startupContext.GetOrAddProperty(_ => fdc3StartupProperties).InstanceId; + + return new Fdc3StartupProperties() + { + AppId = appId, + ChannelId = channelId, + InstanceId = fdc3InstanceId, + OpenedAppContextId = openedAppContextId + }; + } + catch (AppNotFoundException exception) + { + throw exception; + } + } +} diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3DesktopAgentService.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3DesktopAgentService.cs index ffbacbdb2..c6d34c9cf 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3DesktopAgentService.cs +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3DesktopAgentService.cs @@ -71,7 +71,6 @@ public Fdc3DesktopAgentService( IOptions options, IResolverUICommunicator resolverUI, IUserChannelSetReader userChannelSetReader, - IChannelSelector? channelSelector = null, ILoggerFactory? loggerFactory = null) { _appDirectory = appDirectory; diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3ShutdownAction.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3ShutdownAction.cs index e848faaf9..8cc904ebb 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3ShutdownAction.cs +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3ShutdownAction.cs @@ -34,35 +34,32 @@ public Fdc3ShutdownAction( public async Task InvokeAsync(ShutdownContext shutDownContext, Func next, TimeSpan timeout = default) { - if (shutDownContext.ModuleInstance.Manifest.ModuleType == ModuleType.Web) - { - var desktopAgent = _serviceProvider.GetRequiredService(); + var desktopAgent = _serviceProvider.GetRequiredService(); - var fdc3InstanceId = shutDownContext.ModuleInstance.GetProperties().FirstOrDefault()?.InstanceId; + var fdc3InstanceId = shutDownContext.ModuleInstance.GetProperties().FirstOrDefault()?.InstanceId; - if (fdc3InstanceId != null) + if (fdc3InstanceId != null) + { + if (timeout == default) { - if (timeout == default) - { - timeout = DefaultTimeout; - } + timeout = DefaultTimeout; + } - using var cts = new CancellationTokenSource(timeout); + using var cts = new CancellationTokenSource(timeout); - try - { - await desktopAgent.CloseModule(fdc3InstanceId, cts.Token); - } - catch(OperationCanceledException) - { - _logger.LogError("Timeout: Couldn't finish cleanup task in time. Failed to close module."); - throw; - } - catch (Exception ex) - { - _logger.LogError($"Clouldn't close module: {ex.Message}"); - throw; - } + try + { + await desktopAgent.CloseModule(fdc3InstanceId, cts.Token); + } + catch (OperationCanceledException) + { + _logger.LogError("Timeout: Couldn't finish cleanup task in time. Failed to close module."); + throw; + } + catch (Exception ex) + { + _logger.LogError($"Couldn't close module: {ex.Message}"); + throw; } } diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3StartupAction.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3StartupAction.cs index 5a7d78a61..d1556bd04 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3StartupAction.cs +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3StartupAction.cs @@ -15,19 +15,24 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.DependencyInjection; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Extensions; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Infrastructure.Internal; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; using MorganStanley.ComposeUI.ModuleLoader; namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent; +/// +/// Default implementation how to handle FDC3 related startup actions for different module types in the FDC3 Desktop Agent infrastructure. +/// It retrieves necessary information from the app directory and user channel set, and delegates the handling to specific module handlers based on the module type. +/// internal sealed class Fdc3StartupAction : IStartupAction { private readonly IAppDirectory _appDirectory; private readonly IUserChannelSetReader _userChannelSetReader; private readonly Fdc3DesktopAgentOptions _options; private readonly ILogger _logger; - private static readonly Dictionary Handlers = new() + private static readonly Dictionary Handlers = new() { { ModuleType.Web, new WebStartupModuleHandler() }, { ModuleType.Native, new NativeStartupModuleHandler() } @@ -49,32 +54,14 @@ public async Task InvokeAsync(StartupContext startupContext, Func next) { try { - var appId = (await _appDirectory.GetApp(startupContext.StartRequest.ModuleId)).AppId; - var userChannelSet = await _userChannelSetReader.GetUserChannelSet().ConfigureAwait(false); + var properties = await startupContext.GetFdc3Properties(_appDirectory, _userChannelSetReader, _logger).ConfigureAwait(false); - var fdc3InstanceId = startupContext - .StartRequest - .Parameters - .FirstOrDefault(parameter => parameter.Key == Fdc3StartupParameters.Fdc3InstanceId).Value - ?? Guid.NewGuid().ToString(); - - var channelId = startupContext - .StartRequest - .Parameters - .FirstOrDefault(parameter => parameter.Key == Fdc3StartupParameters.Fdc3ChannelId).Value ?? _options.ChannelId - ?? userChannelSet.FirstOrDefault().Key; - - var openedAppContextId = startupContext - .StartRequest - .Parameters - .FirstOrDefault(x => x.Key == Fdc3StartupParameters.OpenedAppContextId).Value; - - var fdc3StartupProperties = new Fdc3StartupProperties { InstanceId = fdc3InstanceId, ChannelId = channelId, OpenedAppContextId = openedAppContextId }; - fdc3InstanceId = startupContext.GetOrAddProperty(_ => fdc3StartupProperties).InstanceId; + var fdc3StartupProperties = new Fdc3StartupProperties { AppId = properties.AppId, InstanceId = properties.InstanceId, ChannelId = properties.ChannelId ?? _options.ChannelId, OpenedAppContextId = properties.OpenedAppContextId }; + var fdc3InstanceId = startupContext.GetOrAddProperty(_ => fdc3StartupProperties).InstanceId; if (Handlers.TryGetValue(startupContext.ModuleInstance.Manifest.ModuleType, out var handler)) { - await handler.HandleAsync(startupContext, appId, fdc3InstanceId, channelId, openedAppContextId).ConfigureAwait(false); + await handler.HandleAsync(startupContext, fdc3StartupProperties).ConfigureAwait(false); } } catch (AppNotFoundException exception) @@ -82,6 +69,6 @@ public async Task InvokeAsync(StartupContext startupContext, Func next) _logger.LogError(exception, $"Fdc3 bundle js could be not added to the {startupContext.StartRequest.ModuleId}."); } - await next.Invoke(); + await next.Invoke().ConfigureAwait(false); } } diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/IChannelSelector.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/IChannelSelector.cs deleted file mode 100644 index 666adb33d..000000000 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/IChannelSelector.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Morgan Stanley makes this available to you under the Apache License, -// Version 2.0 (the "License"). You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0. -// -// See the NOTICE file distributed with this work for additional information -// regarding copyright ownership. Unless required by applicable law or agreed -// to in writing, software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express -// or implied. See the License for the specific language governing permissions -// and limitations under the License. - -using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol; - -namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent; - -/// -/// Adds ability to select user channels from the FDC3 windows/applications by showing on its UI - depends on the implementation. -/// -public interface IChannelSelector -{ - /// - /// Sends a request to the FDC3 service to retrieve the user channels set by the user. - /// - /// - /// - public Task> GetUserChannelsAsync(CancellationToken cancellationToken = default); -} \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/StartupModuleHandler.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/IStartupModuleHandler.cs similarity index 62% rename from src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/StartupModuleHandler.cs rename to src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/IStartupModuleHandler.cs index 17318b5e3..bb4040e09 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/StartupModuleHandler.cs +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/IStartupModuleHandler.cs @@ -10,22 +10,20 @@ // or implied. See the License for the specific language governing permissions // and limitations under the License. +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; using MorganStanley.ComposeUI.ModuleLoader; namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Infrastructure.Internal; /// -/// Abstract base class for handling startup actions for different module types. +/// Interface for handling FDC3 related startup actions for different module types. /// -internal abstract class StartupModuleHandler +internal interface IStartupModuleHandler { /// /// Executes the startup logic for the specified module type. /// /// The startup context for the module. - /// The application identifier. - /// The FDC3 instance identifier. - /// The channel identifier, if any. - /// The opened app context identifier, if any. - public abstract Task HandleAsync(StartupContext startupContext, string appId, string fdc3InstanceId, string? channelId, string? openedAppContextId); + /// Fdc3 startup properties + public Task HandleAsync(StartupContext startupContext, Fdc3StartupProperties fdc3StartupProperties); } diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/NativeStartupModuleHandler.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/NativeStartupModuleHandler.cs index 39f586c71..25e2eb78e 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/NativeStartupModuleHandler.cs +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/NativeStartupModuleHandler.cs @@ -19,25 +19,25 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Infrastructure.Internal; /// /// Handles startup logic for native modules by setting environment variables. /// -internal sealed class NativeStartupModuleHandler : StartupModuleHandler +internal sealed class NativeStartupModuleHandler : IStartupModuleHandler { /// - public override Task HandleAsync(StartupContext startupContext, string appId, string fdc3InstanceId, string? channelId, string? openedAppContextId) + public Task HandleAsync(StartupContext startupContext, Fdc3StartupProperties fdc3StartupProperties) { var envs = new Dictionary { - { nameof(AppIdentifier.AppId), appId }, - { nameof(AppIdentifier.InstanceId), fdc3InstanceId } + { nameof(AppIdentifier.AppId), fdc3StartupProperties.AppId }, + { nameof(AppIdentifier.InstanceId), fdc3StartupProperties.InstanceId } }; - if (channelId != null) + if (fdc3StartupProperties.ChannelId != null) { - envs.Add(nameof(Fdc3StartupProperties.ChannelId), channelId); + envs.Add(nameof(Fdc3StartupProperties.ChannelId), fdc3StartupProperties.ChannelId); } - if (openedAppContextId != null) + if (fdc3StartupProperties.OpenedAppContextId != null) { - envs.Add(nameof(Fdc3StartupProperties.OpenedAppContextId), openedAppContextId); + envs.Add(nameof(Fdc3StartupProperties.OpenedAppContextId), fdc3StartupProperties.OpenedAppContextId); } startupContext.AddProperty(new EnvironmentVariables(envs)); diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/WebStartupModuleHandler.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/WebStartupModuleHandler.cs index 4c6f72bf6..91ebf899a 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/WebStartupModuleHandler.cs +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/WebStartupModuleHandler.cs @@ -11,6 +11,7 @@ // and limitations under the License. using System.Text; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; using MorganStanley.ComposeUI.ModuleLoader; using ResourceReader = MorganStanley.ComposeUI.Utilities.ResourceReader; @@ -19,10 +20,10 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Infrastructure.Internal; /// /// Handles startup logic for web modules by injecting configuration scripts. /// -internal sealed class WebStartupModuleHandler : StartupModuleHandler +internal sealed class WebStartupModuleHandler : IStartupModuleHandler { /// - public override Task HandleAsync(StartupContext startupContext, string appId, string fdc3InstanceId, string? channelId, string? openedAppContextId) + public Task HandleAsync(StartupContext startupContext, Fdc3StartupProperties fdc3StartupProperties) { var webProperties = startupContext.GetOrAddProperty(); @@ -31,25 +32,25 @@ public override Task HandleAsync(StartupContext startupContext, string appId, st window.composeui.fdc3 = { ...window.composeui.fdc3, config: { - appId: "{{appId}}", - instanceId: "{{fdc3InstanceId}}" + appId: "{{fdc3StartupProperties.AppId}}", + instanceId: "{{fdc3StartupProperties.InstanceId}}" } """); - if (channelId != null) + if (fdc3StartupProperties.ChannelId != null) { stringBuilder.Append($$""" , - channelId: "{{channelId}}" + channelId: "{{fdc3StartupProperties.ChannelId}}" """); } - if (openedAppContextId != null) + if (fdc3StartupProperties.OpenedAppContextId != null) { stringBuilder.Append($$""" , openAppIdentifier: { - openedAppContextId: "{{openedAppContextId}}" + openedAppContextId: "{{fdc3StartupProperties.OpenedAppContextId}}" } """); } diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/ModuleChannelSelector.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/ModuleChannelSelector.cs index 05059b887..7d46af2f7 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/ModuleChannelSelector.cs +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/ModuleChannelSelector.cs @@ -12,73 +12,5 @@ * and limitations under the License. */ -using System.Text.Json; -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; - -internal class ModuleChannelSelector : IModuleChannelSelector -{ - private readonly ILogger _logger; - private readonly IMessaging _messaging; - private IAsyncDisposable? _handler; - private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization; - - public ModuleChannelSelector( - IMessaging messaging, - ILogger? logger = null) - { - _messaging = messaging; - _logger = logger ?? NullLogger.Instance; - } - - public async ValueTask RegisterChannelSelectorHandlerInitiatedFromClientsAsync( - string fdc3InstanceId, - Action onChannelJoined, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrEmpty(fdc3InstanceId)) - { - throw ThrowHelper.MissingInstanceId(nameof(RegisterChannelSelectorHandlerInitiatedFromClientsAsync)); - } - - _handler = await _messaging.RegisterServiceAsync( - Fdc3Topic.ChannelSelectorFromAPI(fdc3InstanceId), - (channelId) => - { - _logger.LogDebug("Request for instance: {InstanceId} was received with content: {ChannelId}", fdc3InstanceId, channelId); - - onChannelJoined(channelId); - - return new ValueTask(channelId); - }, - cancellationToken).ConfigureAwait(false); - } - - public async ValueTask InvokeJoinUserChannelFromUIAsync(string fdc3InstanceId, string channelId, CancellationToken cancellationToken = default) - { - var request = new JoinUserChannelRequest - { - InstanceId = fdc3InstanceId, - ChannelId = channelId - }; - - var result = await _messaging.InvokeServiceAsync(Fdc3Topic.ChannelSelectorFromUI(fdc3InstanceId), JsonSerializer.Serialize(request, _jsonSerializerOptions), cancellationToken).ConfigureAwait(false); - return result; - } - - public ValueTask DisposeAsync() - { - if (_handler != null) - { - return _handler.DisposeAsync(); - } - - return new ValueTask(); - } -} \ No newline at end of file +// This file is kept for backwards compatibility. The implementation has been moved to the Shared library. +// Use MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.ModuleChannelSelector directly. \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentServiceTestsBase.cs b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentServiceTestsBase.cs index d116cd77a..59bdc77d7 100644 --- a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentServiceTestsBase.cs +++ b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentServiceTestsBase.cs @@ -33,7 +33,6 @@ public abstract class Fdc3DesktopAgentServiceTestsBase : IAsyncLifetime protected Mock ResolverUICommunicator { get; } = new(); internal Mock> Logger { get; } = new(); protected Mock LoggerFactory { get; } = new(); - protected Mock ChannelSelector { get; } = new(); private readonly ConcurrentDictionary _modules = new(); private IDisposable? _disposable; @@ -66,7 +65,6 @@ public Fdc3DesktopAgentServiceTestsBase(string appDirectorySource) options, ResolverUICommunicator.Object, new UserChannelSetReader(options), - ChannelSelector.Object, LoggerFactory.Object); } diff --git a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Infrastructure/Internal/Fdc3DesktopAgentMessagingServiceTests.cs b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Infrastructure/Internal/Fdc3DesktopAgentMessagingServiceTests.cs index fff44362b..881bd2ec3 100644 --- a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Infrastructure/Internal/Fdc3DesktopAgentMessagingServiceTests.cs +++ b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Infrastructure/Internal/Fdc3DesktopAgentMessagingServiceTests.cs @@ -54,11 +54,9 @@ public partial class Fdc3DesktopAgentMessagingServiceTests : IAsyncLifetime private readonly MockModuleLoader _mockModuleLoader = new(); private readonly ConcurrentDictionary _modules = new(); private IDisposable? _disposable; - private readonly Mock _mockChannelSelector; public Fdc3DesktopAgentMessagingServiceTests() { - _mockChannelSelector = new Mock(); _mockMessaging.Setup(x => x.RegisterServiceAsync(It.IsAny(), It.IsAny(), It.IsAny())).Returns(() => ValueTask.FromResult(new Mock().Object)); var options = new Fdc3DesktopAgentOptions() @@ -75,7 +73,6 @@ public Fdc3DesktopAgentMessagingServiceTests() options, _mockResolverUICommunicator.Object, new UserChannelSetReader(options), - _mockChannelSelector.Object, NullLoggerFactory.Instance), new Fdc3DesktopAgentOptions(), NullLoggerFactory.Instance); From 26748181d8ada710656e7fbe12a57efa3eccfc9e Mon Sep 17 00:00:00 2001 From: lilla28 <36889371+lilla28@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:29:15 +0100 Subject: [PATCH 2/5] fix(fdc3-client) - Fix tests --- .../Infrastructure/DesktopAgentClient.cs | 2 +- .../Infrastructure/IChannelHandler.cs | 8 +++++-- .../Infrastructure/Internal/ChannelHandler.cs | 2 +- .../Internal/ContextListener.cs | 2 +- .../Internal/Protocol/PrivateChannel.cs | 21 +++++++------------ .../test/IntegrationTests/EndToEndTests.cs | 2 +- 6 files changed, 17 insertions(+), 20 deletions(-) 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 b16ab457e..f190f6744 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 @@ -379,7 +379,7 @@ public async Task JoinUserChannel(string channelId) { await _initializationTaskCompletionSource.Task.ConfigureAwait(false); await JoinUserChannelAsync(channelId).ConfigureAwait(false); - await _channelHandler.TriggerChannelSelector(_currentChannel!).ConfigureAwait(false); + await _channelHandler.TriggerChannelSelectorAsync(_currentChannel!).ConfigureAwait(false); } private async Task JoinUserChannelAsync(string channelId) diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelHandler.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelHandler.cs index a71a4bdf9..80e75df1b 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelHandler.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelHandler.cs @@ -44,8 +44,12 @@ public ValueTask> CreateContextListenerAsync( /// A representing the asynchronous operation. public ValueTask JoinUserChannelAsync(string channelId); - - public ValueTask TriggerChannelSelector(IChannel channel); + /// + /// Triggers channel selector for the current instance. + /// + /// + /// + public ValueTask TriggerChannelSelectorAsync(IChannel channel); /// /// Retrieves all available user channels. diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelHandler.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelHandler.cs index dad487ae3..f34f51cf4 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelHandler.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelHandler.cs @@ -215,7 +215,7 @@ public async ValueTask JoinUserChannelAsync(string channelId) return channel; } - public async ValueTask TriggerChannelSelector(IChannel channel) + public async ValueTask TriggerChannelSelectorAsync(IChannel channel) { try { 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 4cc720570..148dfa0e4 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 @@ -116,7 +116,7 @@ async Task UnregisterAndCatchAsync() _unsubscribeCallback(this); } } - catch (Exception exception) + catch (Exception) { throw; } 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 index ab31a43b1..dc1a925fc 100644 --- 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 @@ -436,24 +436,17 @@ private async ValueTask FireContextHandlerAdded(string? contextType) private void RemoveContextHandler(ContextListener contextListener) where T : IContext { - try + //this is being called within a lock once disposing the client. + if (!_isDisconnected) { - _lock.Wait(); - - if (!_isDisconnected) + if (_contextHandlers.Remove(contextListener)) { - if (_contextHandlers.Remove(contextListener)) - { - _logger.LogWarning("The context listener for context type {ContextType} was removed from private channel {ChannelId}.", contextListener.ContextType, Id); - } + _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(); } + + //Fire and forget + _ = FireUnsubscribed(contextListener.ContextType); } private async ValueTask FireUnsubscribed(string? contextType) diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/EndToEndTests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/EndToEndTests.cs index 3a02be51a..f511e3d3a 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/EndToEndTests.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/EndToEndTests.cs @@ -460,7 +460,7 @@ public async Task AddIntentListener_able_to_handle_raised_intent() { "Fdc3InstanceId", Guid.NewGuid().ToString() }, })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. For test only! - await Task.Delay(3000); //We need to wait somehow for the module to finish up its actions + await Task.Delay(5000); //We need to wait somehow for the module to finish up its actions handledContexts.Should().HaveCount(1); } From 7485695a722e8bce106faa3d3247afa7195fe4de Mon Sep 17 00:00:00 2001 From: lilla28 <36889371+lilla28@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:27:22 +0100 Subject: [PATCH 3/5] fix(fdc3-client) - trigger channel joined for UI changes --- .../Infrastructure/DesktopAgentClient.cs | 6 ++-- .../README.md | 36 ++++++++++++++----- 2 files changed, 30 insertions(+), 12 deletions(-) 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 f190f6744..7da32e1fc 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 @@ -147,13 +147,11 @@ private async Task InitializeAsync(string? channelId, string? openedAppContextId } finally { - //The responsibility of triggering the channel selector using native client is the container's since the desktop agent client doesn't have the information about the UI. - //If the channelId is provided through startup parameters, the desktop agent client will join the channel directly without triggering the channel selector. - //In this case, we need to trigger the channel selector to update the UI in order to reflect the current channel that the app has joined. - //Because it can cause deadlock to trigger the channel selector within the lock in the JoinUserChannelAsync, we trigger it here after the initialization is completed and the channel is joined. + //It can cause deadlock to trigger the channel selector within the lock in the JoinUserChannelAsync, we trigger it here after the initialization is completed and the channel is joined. if (!string.IsNullOrEmpty(channelId)) { await JoinUserChannelAsync(channelId!).ConfigureAwait(false); + _ = _channelHandler.TriggerChannelSelectorAsync(_currentChannel!).ConfigureAwait(false); } onReady?.Invoke(this); 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 34c6b0e6d..2071219b7 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 @@ -153,12 +153,7 @@ Native clients must handle channel selection UI independently since the Desktop Each container instance should register its own channel selector logic by subscribing to the `Fdc3Topic.ChannelSelectorFromAPI({instanceId})` topic. This service is invoked whenever `JoinUserChannel` is called from the API, allowing the UI to update and reflect the currently joined user channel. #### Initial User Channel Behavior -When the Desktop Agent Client is initialized with an initial user channel (via `Fdc3StartupProperties.ChannelId` or environment variable), the implementation joins the channel **without triggering the registered channel selector service**. This is intentional because: -- The container already knows the initial channel value (it provided it during startup) -- This avoids unnecessary round-trips and potential UI flickering during initialization -- The container can immediately display the correct initial state without waiting for a callback - -The container is responsible for displaying the initial user channel state in its UI channel selector component. +When the Desktop Agent Client is initialized with an initial user channel (via `Fdc3StartupProperties.ChannelId` or environment variable), the implementation joins the channel **also triggering the registered channel selector service in the background**. #### JoinUserChannel Triggers the Channel Selector After initialization, whenever `JoinUserChannel(channelId)` is called (either by the application or via the UI), the implementation: @@ -182,13 +177,38 @@ await messaging.RegisterServiceAsync( }); // Example: UI triggering a channel join -var request = new JoinUserChannelRequest { ChannelId = "fdc3.channel.1" }; +var request = new JoinUserChannelRequest { ChannelId = "fdc3.channel.1", InstanceId = instanceId }; await messaging.InvokeServiceAsync( Fdc3Topic.ChannelSelectorFromUI(instanceId), JsonSerializer.Serialize(request)); ``` -> **Note:** The initial user channel provided during startup will not trigger the channel selector callback. The container must handle displaying the initial channel state itself based on the `Fdc3StartupProperties.ChannelId` value it provided. +#### Default module channel selector provided by the Desktop Agent backend +The container registers a handler for the `Fdc3Topic.ChannelSelectorFromAPI({instanceId})` topic, which updates the current channel in the Desktop Agent Client when a channel is joined via the API. +This allows the Desktop Agent Client to maintain accurate state of the currently joined channel and ensures that any UI components subscribed to this topic can update accordingly. +It also provides functionality to trigger the module instance's client to join a channel when the `Fdc3Topic.ChannelSelectorFromUI({instanceId})` service is invoked, enabling seamless integration between the UI and the Desktop Agent Client for channel management. +As mentioned above, the Desktop Agent Client will join the specified channel and trigger the registered channel selector service in the background, ensuring that the UI can update to reflect the new channel membership. +This interface `IModuleChannelSelector` is ready to use out of the box for any container that wants to integrate with the Desktop Agent Client, but containers can also choose to implement their own channel selector logic if they want more control over the UI/UX of channel selection. +The clients (both .NET and Typescript) are communicating with the channel selector through a messaging-based approach, allowing for flexibility in how the channel selector is implemented and integrated within the container's UI. +They are using the `Fdc3Topic.ChannelSelectorFromAPI({instanceId})` topic to receive updates about channel joins from the API and the `Fdc3Topic.ChannelSelectorFromUI({instanceId})` service to trigger channel joins from the UI, ensuring a seamless flow of information between the Desktop Agent Client and the container's channel selector UI component. +Once the clients are initialized, they will subscribe to the `Fdc3Topic.ChannelSelectorFromAPI({instanceId})` topic to receive updates about channel joins from the API. +When a channel join occurs via the API, the clients will trigger the registered channel selector service with the new channel ID, allowing the UI to update accordingly -if the service is not found they won't throw any exception. + +Example usage: +```csharp +await _moduleChannelSelector.RegisterChannelSelectorHandlerInitiatedFromClientsAsync(instanceId, (channelId) => +{ + // Update your UI channel selector to display the joined channel + UpdateChannelSelectorUI(channelId); + return ValueTask.FromResult(null); +}); +``` + +To trigger a channel join from the UI, the container can invoke the `Fdc3Topic.ChannelSelectorFromUI({instanceId})` service with a `JoinUserChannelRequest` payload containing the desired channel ID. +The Desktop Agent Client will handle this invocation and call `JoinUserChannel` internally to join the specified channel. +```csharp +var result = await _moduleChannelSelector.InvokeJoinUserChannelFromUIAsync(instanceId, channelId); +``` ### Getting or Creating a App Channel You can get or create an app channel by using: From 2a86cf428305cd57f1d4dbe37110d0f14aa28b4e Mon Sep 17 00:00:00 2001 From: lilla28 <36889371+lilla28@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:16:31 +0200 Subject: [PATCH 4/5] feat(fdc3) - Update documentation --- .../README.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/README.md b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/README.md index d540e053f..a9c7403cd 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/README.md +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/README.md @@ -178,6 +178,66 @@ await _moduleChannelSelector.RegisterChannelSelectorHandlerInitiatedFromClientsA }).ConfigureAwait(false); ``` +### FDC3 startup parameters (instance id, channel, opened app context) + +When a module is started by the `ModuleLoader` it is possible to pass arbitrary startup parameters in the `StartRequest`. +The Desktop Agent uses three well-known keys to convey FDC3-specific information to a newly started module: + +- `Fdc3InstanceId` — a Desktop Agent generated identifier for the specific application instance. This id is used by client libraries to subscribe to per-instance topics (for example the channel selector UI topic). +- `Fdc3ChannelId` — the id of the user channel the opened app should join on startup (optional). +- `OpenedAppContextId` — when an app is opened via `fdc3.open(...)` this id marks the context that should be made available to the opened app when it registers its context listeners. + +These keys are passed as `StartRequest.Parameters` (a collection of `KeyValuePair`) by the code that requests the module to start. Example of starting a module and passing the values: + +```csharp +using MorganStanley.ComposeUI.ModuleLoader; + +var parameters = new[] +{ + new KeyValuePair("Fdc3InstanceId", "instance-123"), + new KeyValuePair("Fdc3ChannelId", "trade-channel"), + new KeyValuePair("OpenedAppContextId", "open-ctx-456") +}; + +var startRequest = new StartRequest("com.mycompany.myModule", parameters); +await moduleLoader.StartModuleAsync(startRequest); +``` + +Retrieval: the Desktop Agent provides an extension helper that extracts these values, resolves the app id from the configured `IAppDirectory`, and stores the resulting `Fdc3StartupProperties` in the `StartupContext` so other startup handlers can access them: + +```csharp +// inside an IStartupAction implementation +public async Task ExecuteAsync(StartupContext startupContext) +{ + // GetFdc3Properties fetches app id, resolves defaults (like a generated instance id or a default channel) + // and stores the properties into the startupContext for other handlers. + var fdc3Props = await startupContext.GetFdc3Properties(appDirectory, userChannelSetReader, logger); + + // use the properties + var instanceId = fdc3Props.InstanceId; // used for client subscriptions + var channelId = fdc3Props.ChannelId; // optional channel to join immediately + var openedAppContextId = fdc3Props.OpenedAppContextId; // used to request initial context +} +``` + +Alternatively consumers can read the `Fdc3StartupProperties` from the `StartupContext` property bag if a previous handler already added it: + +```csharp +var existing = startupContext.GetProperties().FirstOrDefault(); +if (existing != null) +{ + // already set by another handler - reuse it +} +``` + +Why this matters: + +- Deterministic client initialization: client libraries use the `Fdc3InstanceId` so they can subscribe to per-instance messaging channels (for example the channel selector UI topic). When the UI or other modules publish to that topic the client will react only for the matching instance. +- Initial channel join: supplying `Fdc3ChannelId` at startup allows a module to join the intended user channel immediately and wire up top-level context listeners for that channel. +- Opened app context handoff: when an app is started via `fdc3.open(...)`, `OpenedAppContextId` lets the opened app request the specific context it should receive, avoiding race conditions between startup and listener registration. + +This mechanism also allows different desktop agent clients to opt-in or override the channel they should join at initialization time. A module's container (or the code that starts the module) can choose which `Fdc3ChannelId` to pass, so different clients or UI shells can control channel membership per instance. + ## License This project is licensed under the [Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0). From 28e519c56253d4cb7b3a1ceedc60f3c6010462ee Mon Sep 17 00:00:00 2001 From: lilla28 <36889371+lilla28@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:00:59 +0200 Subject: [PATCH 5/5] fix(fdc3-client) - PR comments --- .../Infrastructure/DesktopAgentClient.cs | 49 +++++++------------ 1 file changed, 17 insertions(+), 32 deletions(-) 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 7da32e1fc..ad7262384 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 @@ -56,6 +56,9 @@ public class DesktopAgentClient : IDesktopAgent, IAsyncDisposable /// /// Fdc3App app id /// Fdc3App insatnce id + /// The initial opened app context id which can be provided for the app opened through fdc3.open call. This is needed to properly handle the context for the app opened through fdc3.open. + /// The initial user channel id which can be provided for the app to automatically join to a user channel on initialization. + /// Callback which signals that the desktop agent client is ready for the module. This can be handy to ensure that the client is fully initialized before the module starts using it for Fdc3 operations, preventing potential issues related to uninitialized state. /// public DesktopAgentClient( IMessaging messaging, @@ -70,35 +73,20 @@ public DesktopAgentClient( _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; _logger = _loggerFactory.CreateLogger(); - _appId = Environment.GetEnvironmentVariable(nameof(AppIdentifier.AppId)); - _instanceId = Environment.GetEnvironmentVariable(nameof(AppIdentifier.InstanceId)); + _appId = Environment.GetEnvironmentVariable(nameof(AppIdentifier.AppId)) + ?? appId + ?? throw ThrowHelper.MissingAppIdentifier(appId, instanceId); + + _instanceId = Environment.GetEnvironmentVariable(nameof(AppIdentifier.InstanceId)) + ?? instanceId + ?? throw ThrowHelper.MissingAppIdentifier(appId, instanceId); + + var channelId = Environment.GetEnvironmentVariable(Fdc3StartupParameters.Fdc3ChannelId) + ?? initialUserChannelId; + + var openedAppContextId = Environment.GetEnvironmentVariable(Fdc3StartupParameters.OpenedAppContextId) + ?? initialOpenAppContextId; - if (string.IsNullOrEmpty(_appId) || string.IsNullOrEmpty(_instanceId)) - { - if (string.IsNullOrEmpty(appId) || string.IsNullOrEmpty(instanceId)) - { - throw ThrowHelper.MissingAppIdentifier(appId, instanceId); - } - else - { - _appId = appId!; - _instanceId = instanceId!; - } - } - - var channelId = Environment.GetEnvironmentVariable(Fdc3StartupParameters.Fdc3ChannelId); - if (string.IsNullOrEmpty(channelId) - && !string.IsNullOrEmpty(initialUserChannelId)) - { - channelId = initialUserChannelId; - } - - var openedAppContextId = Environment.GetEnvironmentVariable(Fdc3StartupParameters.OpenedAppContextId); - if (string.IsNullOrEmpty(openedAppContextId) - && !string.IsNullOrEmpty(initialOpenAppContextId)) - { - openedAppContextId = initialOpenAppContextId; - } if (_logger.IsEnabled(LogLevel.Debug)) { @@ -448,10 +436,7 @@ private void LeaveCurrentChannelWithoutLock() contextListener.Key.Unsubscribe(); } - if (_currentChannel != null) - { - _currentChannel = null; - } + _currentChannel = null; } ///