Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
* Added conformance coverage for `ChannelError.NoChannelFound`, `ChannelError.MalformedContext`, and `ChannelError.InvalidArguments`. ([#1779](https://github.com/finos/FDC3/issues/1779))
* Added conformance coverage verifying that Desktop Agent methods continue to work when destructured from the `fdc3` object. ([#1778](https://github.com/finos/FDC3/issues/1778))
* Added standalone Workbench examples for the FDC3 2.2 `fdc3.action`, `fdc3.fileAttachment`, `fdc3.message`, `fdc3.orderList`, `fdc3.tradeList`, and `fdc3.timeRange` context types. ([#1949](https://github.com/finos/FDC3/pull/1949))
* Added `receiveOwnBroadcasts` and `resolveOwnIntents` options to `getAgent`, allowing an application to opt in to receiving its own broadcasts and/or having its own instance considered when resolving intents it raises. Both default to the existing behaviour (an app does not receive its own broadcasts and an intent is not resolved to the raising instance; other instances of the same app remain eligible). The options are carried on the `WCP4ValidateAppIdentity` handshake message and stored per-instance by the Desktop Agent. Updated the API spec, `getAgent`, `broadcast`, `Channel` and `raiseIntent`/`raiseIntentForContext` documentation, and added conformance tests. ([#1751](https://github.com/finos/FDC3/issues/1751), [#1860](https://github.com/finos/FDC3/issues/1860))
* Added advanced conformance tests (`fdc3.intentListenerConflict`) covering intent listener conflicts, verifying that `addIntentListener`/`addIntentListenerWithContext` reject with `ResolveError.IntentListenerConflict` for conflicting listeners (unfiltered, or overlapping context types) and allow non-overlapping filtered listeners, listeners for different intents, and re-adding after `unsubscribe()`. Added the corresponding test definitions to the "Avoiding Adding Multiple Intent Listeners" section of the Intents conformance docs.
* Added a classification field to Instrument context type ([#1665](https://github.com/finos/FDC3/pull/1665))
* Added Go language binding. ([#1483](https://github.com/finos/FDC3/pull/1483))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ export class IdentityValidationHandler {
payload: {
identityUrl,
actualUrl,
...(this.options.receiveOwnBroadcasts !== undefined && {
receiveOwnBroadcasts: this.options.receiveOwnBroadcasts,
}),
...(this.options.resolveOwnIntents !== undefined && {
resolveOwnIntents: this.options.resolveOwnIntents,
}),
},
};

Expand Down
2 changes: 2 additions & 0 deletions packages/fdc3-get-agent/src/strategies/getAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ export const getAgent: GetAgentType = (params?: GetAgentParams) => {
channelSelector: true,
intentResolver: true,
timeoutMs: DEFAULT_GETAGENT_TIMEOUT_MS,
receiveOwnBroadcasts: false,
resolveOwnIntents: false,
//default log levels are set in the relevant logging utils
};

Expand Down
16 changes: 16 additions & 0 deletions packages/fdc3-schema/generated/api/BrowserTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,20 @@ export interface WebConnectionProtocol4ValidateAppIdentityPayload {
* Instance UUID associated with the requested instanceId.
*/
instanceUuid?: string;
/**
* Flag indicating that the application wishes to receive its own broadcasts, i.e. context
* messages it broadcasts to a channel it is joined to (or listening on) should be delivered
* back to it. Defaults to false when omitted, in which case the Desktop Agent SHOULD NOT
* deliver the application's own broadcasts back to it.
*/
receiveOwnBroadcasts?: boolean;
/**
* Flag indicating that the application is willing for its own instance to be considered
* when resolving intents that it raises. Defaults to false when omitted, in which case the
* Desktop Agent SHOULD NOT resolve an intent raised by the application instance to that
* same instance (other instances of the same app remain eligible).
*/
resolveOwnIntents?: boolean;
}

/**
Expand Down Expand Up @@ -5213,6 +5227,8 @@ const typeMap: any = {
{ json: 'identityUrl', js: 'identityUrl', typ: '' },
{ json: 'instanceId', js: 'instanceId', typ: u(undefined, '') },
{ json: 'instanceUuid', js: 'instanceUuid', typ: u(undefined, '') },
{ json: 'receiveOwnBroadcasts', js: 'receiveOwnBroadcasts', typ: u(undefined, true) },
{ json: 'resolveOwnIntents', js: 'resolveOwnIntents', typ: u(undefined, true) },
],
false
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@
"title": "instanceUuid",
"description": "Instance UUID associated with the requested instanceId.",
"type": "string"
},
"receiveOwnBroadcasts": {
"title": "receiveOwnBroadcasts",
"description": "Flag indicating that the application wishes to receive its own broadcasts, i.e. context messages it broadcasts to a channel it is joined to (or listening on) should be delivered back to it. Defaults to false when omitted, in which case the Desktop Agent SHOULD NOT deliver the application's own broadcasts back to it.",
"type": "boolean"
},
"resolveOwnIntents": {
"title": "resolveOwnIntents",
"description": "Flag indicating that the application is willing for its own instance to be considered when resolving intents that it raises. Defaults to false when omitted, in which case the Desktop Agent SHOULD NOT resolve an intent raised by the application instance to that same instance (other instances of the same app remain eligible).",
"type": "boolean"
}
},
"additionalProperties": false,
Expand Down
21 changes: 21 additions & 0 deletions packages/fdc3-standard/src/api/GetAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ export type GetAgentType = (params?: GetAgentParams) => Promise<DesktopAgent>;
* @property {GetAgentLogLevels} logLevels Settings that determine what should
* will logged by the getAgent() implementation and DesktopAgentProxy to the
* JavaScript console.
*
* @property {boolean} receiveOwnBroadcasts Flag indicating that the application
* wishes to receive its own broadcasts, i.e. context messages that it broadcasts
* to a channel it is joined to (or listening on) should be delivered back to it.
* Defaults to `false`, in which case a Desktop Agent SHOULD NOT deliver an
* application's own broadcasts back to it. Applications can distinguish their own
* broadcasts from those of other instances of the same app via the `source`
* (`instanceId`) in the [`ContextMetadata`](ref/Metadata#contextmetadata) provided
* to their context handlers. MAY be ignored by Desktop Agent Preload (container)
* implementations.
*
* @property {boolean} resolveOwnIntents Flag indicating that the application is
* willing for its own instance to be considered when resolving intents that it
* raises (i.e. an intent it raises may be delivered back to the same instance).
* Defaults to `false`, in which case a Desktop Agent SHOULD NOT resolve an intent
* raised by an application instance to that same instance (other instances of the
* same app remain eligible). If enabling this leaves no eligible targets, the
* raising app receives a `NoAppsFound` error. MAY be ignored by Desktop Agent
* Preload (container) implementations.
*/
export type GetAgentParams = {
timeoutMs?: number;
Expand All @@ -94,6 +113,8 @@ export type GetAgentParams = {
dontSetWindowFdc3?: boolean;
failover?: (args: GetAgentParams) => Promise<WindowProxy | DesktopAgent>;
logLevels?: GetAgentLogLevels;
receiveOwnBroadcasts?: boolean;
resolveOwnIntents?: boolean;
};

/**
Expand Down
11 changes: 11 additions & 0 deletions toolbox/fdc3-for-web/fdc3-web-impl/src/ServerContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ export type AppRegistration = {
state: State;
appId: string;
instanceId: InstanceID;

/** Whether this instance opted in (via getAgent) to receiving its own
* broadcasts. When falsy, the Desktop Agent does not deliver context
* messages broadcast by this instance back to it. */
receiveOwnBroadcasts?: boolean;

/** Whether this instance opted in (via getAgent) to having its own instance
* considered when resolving intents that it raises. When falsy, the Desktop
* Agent excludes this instance from resolution of intents it raises (other
* instances of the same app remain eligible). */
resolveOwnIntents?: boolean;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,13 +445,17 @@ export class BroadcastHandler implements MessageHandler {
return r.channelId == null && ucId == arg0.payload.channelId;
};

// Unless the broadcasting app opted in (via getAgent's receiveOwnBroadcasts),
// its own broadcasts are not delivered back to it.
const receiveOwnBroadcasts = sc.getInstanceDetails(from.instanceId)?.receiveOwnBroadcasts ?? false;

const matchingListeners = this.contextListeners
// Deliver the message to apps listening to the right channel
.filter(r => matchesExactChannel(r) || matchesUserChannel(r))
// Deliver the message to apps with matching context type listeners
.filter(r => r.contextType == null || r.contextType == arg0.payload.context.type)
// Don't deliver messages back to the broadcasting app
.filter(r => r.instanceId !== from.instanceId);
// Don't deliver messages back to the broadcasting app (unless it opted in)
.filter(r => receiveOwnBroadcasts || r.instanceId !== from.instanceId);

const matchingApps: FullAppIdentifier[] = matchingListeners
.map(r => {
Expand Down
58 changes: 36 additions & 22 deletions toolbox/fdc3-for-web/fdc3-web-impl/src/handlers/IntentHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,13 @@ export class IntentHandler implements MessageHandler {

async raiseIntentToAnyApp(arg0: IntentRequest[], sc: ServerContext<AppRegistration>): Promise<void> {
const connectedApps = await sc.getConnectedApps();
// Unless the raising app opted in (via getAgent's resolveOwnIntents), its own
// instance is not considered when resolving an intent that it raised. Other
// instances of the same app remain eligible.
const raiser = arg0[0].from;
const raiserResolvesOwnIntents = raiser.instanceId
? (sc.getInstanceDetails(raiser.instanceId)?.resolveOwnIntents ?? false)
: false;
const matchingIntents = arg0.flatMap(i => this.directory.retrieveIntents(i.context.type, i.intent, undefined));
const matchingRegistrations = arg0.flatMap(i =>
this.registrations.filter(
Expand All @@ -439,29 +446,36 @@ export class IntentHandler implements MessageHandler {

const allIntents = this.directory.retrieveAllIntents();

const appIntents: AppIntent[] = uniqueIntentNames.map(i => {
const directoryAppsWithIntent = matchingIntents.filter(mi => mi.intentName == i).map(mi => mi.appId);
const runningDirectoryApps = connectedApps.filter(ca => directoryAppsWithIntent.includes(ca.appId));
const appRegistrations = matchingRegistrations
.filter(registration => registration.intentName === i) // filter registrations for the current intent
.map(listener => ({ appId: listener.appId, instanceId: listener.instanceId, state: State.Connected }))
.filter(appRegistration => allIntents.every(intent => intent.appId !== appRegistration.appId)); // filter out apps that have intents registered in the directory

const runningApps: AppRegistration[] = [...runningDirectoryApps, ...appRegistrations];
const appIntents: AppIntent[] = uniqueIntentNames
.map(i => {
const directoryAppsWithIntent = matchingIntents.filter(mi => mi.intentName == i).map(mi => mi.appId);
const runningDirectoryApps = connectedApps.filter(ca => directoryAppsWithIntent.includes(ca.appId));
const appRegistrations = matchingRegistrations
.filter(registration => registration.intentName === i) // filter registrations for the current intent
.map(listener => ({ appId: listener.appId, instanceId: listener.instanceId, state: State.Connected }))
.filter(appRegistration => allIntents.every(intent => intent.appId !== appRegistration.appId)); // filter out apps that have intents registered in the directory

const runningApps: AppRegistration[] = [...runningDirectoryApps, ...appRegistrations].filter(

@Roaders Roaders Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug confirmed in existing reference implementation. The app that raises the intent is considered as a target for the intent:

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we add a conformance test that ensures that an app will return a result rather than select itself as an intent target?

app => raiserResolvesOwnIntents || app.instanceId !== raiser.instanceId
);

return {
intent: {
name: i,
displayName: i,
},
apps: [
...runningApps,
...directoryAppsWithIntent.map(d => {
return { appId: d };
}),
],
};
});
return {
intent: {
name: i,
displayName: i,
},
apps: [
...runningApps,
...directoryAppsWithIntent.map(d => {
return { appId: d };
}),
],
};
// Drop any intent left with no resolvers (e.g. because the only handler was
// the raising instance, which was excluded above), so the caller receives a
// NoAppsFound error rather than an empty resolution.
})
.filter(appIntent => appIntent.apps.length > 0);

const narrowedAppIntents = await this.narrowIntents(arg0[0].from, appIntents, arg0[0].context, sc);

Expand Down
10 changes: 10 additions & 0 deletions toolbox/fdc3-for-web/fdc3-web-impl/src/handlers/OpenHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,13 @@ export class OpenHandler implements MessageHandler {
sc.post(msg, instanceId);
};

// Self-interaction preferences supplied by the app via getAgent, captured
// per-instance (each instance of an app may connect with different options).
const applySelfInteractionOptions = (registration: AppRegistration) => {
registration.receiveOwnBroadcasts = arg0.payload.receiveOwnBroadcasts ?? false;
registration.resolveOwnIntents = arg0.payload.resolveOwnIntents ?? false;
};

if (arg0.payload.instanceUuid) {
// existing app reconnecting
console.debug('App attempting to reconnect:', arg0.payload.instanceUuid);
Expand All @@ -378,6 +385,7 @@ export class OpenHandler implements MessageHandler {
', instanceId',
arg0.payload.instanceUuid
);
applySelfInteractionOptions(appIdentity);
sc.setInstanceDetails(from, appIdentity);
sc.setAppState(from, State.Connected);
return returnSuccess(appIdentity.appId, appIdentity.instanceId);
Expand All @@ -390,6 +398,8 @@ export class OpenHandler implements MessageHandler {
// we need to assign an identity to this app - this should have been generated when it was launched
const appIdentity = sc.getInstanceDetails(from);
if (appIdentity) {
applySelfInteractionOptions(appIdentity);
sc.setInstanceDetails(from, appIdentity);
sc.setAppState(appIdentity.instanceId, State.Connected);
returnSuccess(appIdentity.appId, appIdentity.instanceId);

Expand Down
10 changes: 10 additions & 0 deletions toolbox/fdc3-for-web/fdc3-web-impl/test/features/apps.feature
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ Feature: Opening and Requesting App Details
| WCP5ValidateAppIdentityResponse | {null} | {null} | storageApp | uuid-0 | uuid-0 | storageApp |
| openResponse | storageApp | uuid-0 | {null} | {null} | a1 | libraryApp |

Scenario: Self-interaction options supplied during validation are stored against the instance
When "libraryApp/a1" opens app "storageApp"
And "uuid-0" sends validate opting in to "receiveOwnBroadcasts"
Then messaging will have outgoing posts
| msg.matches_type | msg.payload.appId | to.instanceId | to.appId |
| WCP5ValidateAppIdentityResponse | storageApp | uuid-0 | storageApp |
| openResponse | {null} | a1 | libraryApp |
And instance "uuid-0" has "receiveOwnBroadcasts" equal to "true"
And instance "uuid-0" has "resolveOwnIntents" equal to "false"

Scenario: Storage App Reconnects
When "libraryApp/a1" opens app "storageApp"
And "uuid-0" sends validate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,37 @@ Feature: Relaying Broadcast messages
| contextListenerUnsubscribeResponse | App2 | a2 | {null} |
| broadcastResponse | App1 | a1 | {null} |

Scenario: Broadcasting app does not receive its own broadcast by default
When "App1/a1" adds a context listener on "one" with type "fdc3.instrument"
And we wait for a period of "100" ms
And "App1/a1" broadcasts "fdc3.instrument" on "one"
Then messaging will have outgoing posts
| msg.matches_type | msg.payload.channelId | msg.payload.context.type |
| addContextListenerResponse | {null} | {null} |
| broadcastResponse | {null} | {null} |

Scenario: Broadcasting app receives its own broadcast when opted in
When "App1/a1" has opted in to "receiveOwnBroadcasts"
And "App1/a1" adds a context listener on "one" with type "fdc3.instrument"
And we wait for a period of "100" ms
And "App1/a1" broadcasts "fdc3.instrument" on "one"
Then messaging will have outgoing posts
| msg.matches_type | msg.payload.channelId | msg.payload.context.type | msg.payload.context.id.ticker | to.instanceId | msg.payload.metadata.source.instanceId |
| addContextListenerResponse | {null} | {null} | {null} | a1 | {null} |
| broadcastEvent | one | fdc3.instrument | AAPL | a1 | a1 |
| broadcastResponse | {null} | {null} | {null} | a1 | {null} |

Scenario: Opting in to own broadcasts does not affect other apps receiving them
When "App1/a1" has opted in to "receiveOwnBroadcasts"
And "App2/a2" adds a context listener on "one" with type "fdc3.instrument"
And we wait for a period of "100" ms
And "App1/a1" broadcasts "fdc3.instrument" on "one"
Then messaging will have outgoing posts
| msg.matches_type | msg.payload.channelId | msg.payload.context.type | to.instanceId | msg.payload.metadata.source.instanceId |
| addContextListenerResponse | {null} | {null} | a2 | {null} |
| broadcastEvent | one | fdc3.instrument | a2 | a1 |
| broadcastResponse | {null} | {null} | a1 | {null} |

Scenario: Retrieve an existing user channel via getOrCreateChannel
When "App1/a1" creates or gets an app channel called "one"
Then messaging will have outgoing posts
Expand Down
Loading
Loading