Skip to content

Spec for Reunion SDK's Clipboard API#119

Closed
metathinker wants to merge 27 commits intomicrosoft:masterfrom
metathinker:undocked-clipboard-api
Closed

Spec for Reunion SDK's Clipboard API#119
metathinker wants to merge 27 commits intomicrosoft:masterfrom
metathinker:undocked-clipboard-api

Conversation

@metathinker
Copy link
Copy Markdown

@metathinker metathinker commented Jul 17, 2020

This is a specification for a new Windows Clipboard API that I propose we add to the Project Reunion SDK, following on from the discussion in issue #63.

This API does not try to solve any of the major concerns that you all have raised with the Clipboard's developer features and usability (such as in issues #62 and #67). However, it is intended to get us ready to solve those concerns in a way that's compatible with older apps and app versions while leaving behind the warts that those older apps had to deal with.

So what does this API do? Nothing by itself - its initial implementation will likely be a thin wrapper around the "UWP" clipboard API of Windows.ApplicationModel.DataTransfer.Clipboard and friends. But it could allow a completely separate implementation in the future, which enables improvements as discussed in the spec (such as regularizing background clipboard access for UWP apps, as requested in #62).

Your feedback is appreciated!


In the initial iteration of this PR, the Reunion clipboard API is exactly the same as the UWP clipboard API, with these exceptions:

  1. Clipboard.Flush() is now completely optional. As discussed in Discussion: What do you want in a better clipboard API for Windows? #63, the requirement for Clipboard.Flush() often causes data copied to the clipboard to disappear when the source app is closed, since the data values were never actually saved somewhere that outlasts the source app.
  2. DataPackage omits the ShareCompleted and ShareCanceled events, which are specific to DataPackage objects being used to Share data from one app to another, and are not relevant for clipboard copy and paste.
  3. DataPackage omits the SetUri() method, deprecated since Windows 8.1.

The reason I made this choice is that the UWP clipboard API already avoids most of the usability issues I discussed in #63. Because it's the easiest to use of the 3 clipboard APIs, it needed the fewest breaking changes to set it up to make it better.

…tView

Both exist so they can `require` their implementors to also implement
IMap/IMapView<String, Object> and IIterable<IKeyValuePair<String, Object>>.

In the corresponding IDL from the 20H1/Vb Windows SDK, only
IDataPackagePropertySet does have the `require`. However, from what I
can tell, IDataPackagePropertySetView also had it in releases before
1809/RS5, and it looks like the `require` was mistakenly omitted when we
converted the internal IDL corresponding to the SDK IDL from MIDLRT to MIDL3.
DataProviderRequest
DataProviderDeferral
DataProviderHandler
DataPackage.ShareCompleted
DataPackage.ShareCanceled

DataTransferManager
DataRequest
DataRequestDeferral
DataRequestedEventArgs
ShareCompletedEventArgs
ShareProvider
ShareProviderHandler
ShareProviderOperation
ShareProvidersRequestedEventArgs
ShareTargetInfo
ShareUITheme
ShareUIOptions
SharedStorageAccessManager
TargetApplicationChosenEventArgs
- remove the empty class ClipboardHistoryChangedEventArgs;
  change event Clipboard.HistoryChanged to take a generic
  IInspectable as the event handler input arg

- remove all explicitly set interface GUIDs;
  let midlrt.exe generate IIDs instead

- remove `typedef enum Blah Blah` as that is not needed in MIDL 3
…at version of modern/UWP it first appeared in'
@ghost ghost added the needs-triage label Jul 17, 2020
Copy link
Copy Markdown
Author

@metathinker metathinker left a comment

Choose a reason for hiding this comment

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

Copying over some comments I made on a self-review of a previous version of this spec.

Comment on lines +140 to +141
In fact, in this initial version (as of mid-2020), the Reunion
SDK clipboard API is the same as Windows.ApplicationModel.DataTransfer,
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This does mean that we import the asynchronous model of most UWP classes, like the ones in Windows.ApplicationModel.DataTransfer. @usvoyager suggested at one point we should review the async behavior for usability. I forget what he was referring to but I think it had to do with the inconsistency of sync versus async APIs in DataTransfer.


## Appendix

Initially, the implementation of the Project Reunion SDK's clipboard APi
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

typo: s/APi/API/g

DataPackagePropertySet Properties { get; };
DataPackageOperation RequestedOperation;

event Windows.Foundation.TypedEventHandler<DataPackage, OperationCompletedEventArgs> OperationCompleted;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The documentation for this event DataPackage.OperationCompleted doesn't say when your app should handle the event. I should either answer that question or delete the event.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

My best guess is that you handle OperationCompleted in order to know when you can delete any resources associated with a delay-rendered format. If true, this gives the OperationCompletedEventArgs.AcceptedFormatId property some use, as you need to know what format was read in order to know whether you delay-rendered it or not.

DataPackageOperation RequestedOperation;

event Windows.Foundation.TypedEventHandler<DataPackage, OperationCompletedEventArgs> OperationCompleted;
event Windows.Foundation.TypedEventHandler<DataPackage, Object> Destroyed;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

We should probably delete this event. The documentation for DataPackage.Destroyed says "In general, the system handles the destruction of a DataPackage object. Your app should not have to handle this event." I agree as I can't imagine any use for it not already covered by OperationCompleted. Plus it confuses the strange ownership model of DataPackage (does the system own it? if so, why does the source app still need to care about its lifetime?) even more.

But before deleting the event, I should dig up the original Windows 8-era internal API specs to see why the event was added originally.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One speculation I have here is that caller might use this event to release the resources or do the cleanup of the content that it was putting into the data package. For example, I put a bunch of storage items in the data package that are located in Temp folder of some sorts - when package is reported as Deleted, I guess it's safe to schedule the clean up of those? Similar thing with putting stream data (as bitmap or any private format) that might be backed with storage file or other expensive resource. Just a guess though, I don't know for sure

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I think you could be right, Andrew, but then that doesn't explain why we have to have both OperationCompleted and Destroyed. I'd argue that if Destroyed is for letting the source app know when it can release resources needed for data package rendering, then OperationCompleted is not useful at all for DataPackages on the clipboard. (You can't take any action if the data item copied from your app has just been pasted, because the user can paste it again as much as they like.)

I went back to the original Win8-era API spec for Data Package; the descriptions of the events (copied below) were still unclear to me:

  • OperationCompleted: "This event fires when a paste operation is completed."
  • Destroyed: "This event fires when the databpackage is destroyed." [sic]

Those descriptions, coupled with the existence of OperationCompletedEventArgs and its AcceptedFormatId property suggests to me that OperationCompleted was intended to be the "it's ok to free your temp memory/delete your temp files" event, which leads us back to the start of this thread: why have Destroyed?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

(The original author of the Data Package specs, M.B., is now in a different division of Microsoft; I could ask him if he still remembers why he did what he did 9 or 10 years ago but I suspect he doesn't :)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Yes, I really wonder what kind of handling the caller can do on "paste operation is completed", given that paste can happen again and again, including other target apps. Maybe it's something specific for move/cut operation?

String Description;
Windows.Storage.Streams.IRandomAccessStreamReference Thumbnail;
Windows.Foundation.Collections.IVector<String> FileTypes { get; };
String ApplicationName;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This and some other DataPackagePropertySet properties assume that the DataPackage comes from an AppX/MSIX-packaged app. That was almost always true when this API was in the UWP API surface. Do we want to encode that assumption in the Reunion SDK's API?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I recommend we simply make it possible for an app to set these manually. If the app is MSIX-packaged, then default values will be provided; if not, simply leave these blank and let the app take care of them.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think in many cases we can make things even better here. For most apps, even unpackaged, Windows have a way (actually, many ways. I would even say, too many ways) to get the approximation of an application name for non-packaged apps as well. It can be things that are internally known as strong or weak AUMIDs, or just an executable name. In any case, it's still better than empty string I think

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I like both your ideas. To adopt either, we'd have to make clear that the app identity and other metadata here are not suitable for making any security decisions, but I don't think anyone will have a problem with that decision.

Comment on lines +399 to +401
runtimeclass OperationCompletedEventArgs
// FIXME: since the namespace is now ApplicationModel, the name OperationCompletedEventArgs
// is too generic. Should we rename it?
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

If we do end up keeping the DataPackage.OperationCompleted event (see my comment on that event), we should probably rename this class, as the FIXME comment says. Perhaps DataPackageOperationCompletedEventArgs?

{
DataPackageOperation Operation { get; };

String AcceptedFormatId { get; };
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This property is really poorly named, and the documentation does little more than repeat the name. I should investigate what the property even does and delete it if I can't figure out what it contains or why an app would want to read it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

My best guess before doing that investigation is that on a paste (Clipboard.GetContent() followed by DataPackageView.GetDataAsync(format) on the return value from Clipboard), the DataPackage.OperationCompleted event is signaled, with this property set to the name of the data format that the target app read out.

@jonwis jonwis self-requested a review July 17, 2020 18:39
@jonwis jonwis requested a review from ShawnSteele July 17, 2020 18:40
to use the Reunion clipboard API and therefore take advantage of
this feature.

* _Clipboard history:_ Windows 10 version 1809 (October/November 2018 update)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👏 I really like the idea of decoupling the clipboard history screen from Windows proper, because many of the newer in-box features were designed to urge/trick/force users into enabling unrelated “slurpy” features the user may or may not want. (For example, you cannot productively use Activity History without letting Microsoft see all your activities, as the local-only implementation is hobbled to only store 24 hours’ worth of history IIRC; it also displays intrusive nag messages.) I do not remember if Clipboard history has this issue, or how tightly it was coupled with cloud-based Clipboard sync (which I view as a significant privacy concern; I do not want Microsoft to be able to read everything I put on my clipboard, including passwords being copied from my password manager into other apps).

Corollary to the above: If/when we add a Reunion-based clipboard history app, I would want to disable and hide the Windows implementation, both for privacy reasons and to avoid having two similar but incompatible implementations on the system, conflicting with each other and confusing the user.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For the record, I want to call out that both clipboard history and cloud sync are not enabled by default (user needs to opt in), and that opt-in is separate for clipboard history and cloud sync (similarly, they can be enabled/disabled independently).

for the clipboard history UI, while keeping the shared memory store
and API implementations as part of the Reunion SDK.)

* _Cloud clipboard sync and roaming:_ Windows 10 version 1809 also
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I am wondering if it be possible to be able to add a different back-end for cloud clipboard sync. For example, if I use Dropbox, would it be possible to store the cloud clipboard sync information in my Dropbox folder and rely on Dropbox’s storage and security features instead of Microsoft’s? (I personally use OneDrive, but I know several users who cannot use OneDrive due to confidentiality concerns, but can use Dropbox without issue.)

Even if we can’t do that, would it be possible to open-source the sync engine as well?

The only exception will be the change to make [Clipboard.Flush()](
https://docs.microsoft.com/uwp/api/windows.applicationmodel.datatransfer.clipboard.flush)
a completely optional operation.
(TO CONSIDER: Should Clipboard.Flush() be removed entirely?)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In a word: yes.

String Description;
Windows.Storage.Streams.IRandomAccessStreamReference Thumbnail;
Windows.Foundation.Collections.IVector<String> FileTypes { get; };
String ApplicationName;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I recommend we simply make it possible for an app to set these manually. If the app is MSIX-packaged, then default values will be provided; if not, simply leave these blank and let the app take care of them.

/// a delay rendering, with the ability to signal the system when done.
/// </summary>
[contract(ClipboardContract, 1.0)]
runtimeclass DataProviderDeferral
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I’m really not a fan of these “deferral” types. More specifically, when I am writing an AppContainer app, I may need time to ensure that all of my unsaved data has been flushed to persistent storage. However, even if I take a deferral, the system is still able to interrupt my saving code, potentially causing data corruption or loss. Worse yet, the documentation does not definitively state under what circumstances my deferral will be ignored (it’s “up to Windows” if this happens or not). I’d appreciate it if we could take then into mind when documenting the API surface.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Hm, that sounds more like a general problem with the BlahDeferral concept than specific to clipboard or app-to-app data transfer. If you don't mind, could you fork that into a separate issue or discussion?

The only exception will be the change to make [Clipboard.Flush()](
https://docs.microsoft.com/uwp/api/windows.applicationmodel.datatransfer.clipboard.flush)
a completely optional operation.
(TO CONSIDER: Should Clipboard.Flush() be removed entirely?)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

As discussed in an internal thread, let's be careful with removing this. Flushing by default would be
a) absolute opposite of what OLE clipboard layer does (delay render by default)
b) make clipboard change operation potentially very expensive for the apps, and most likely wasteful, since there might not be any consumers for some of the formats that app is advertising

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It was suggested internally that we could combine auto-flushing by default with a way for apps to opt out of auto-flushing specified formats. Do you think that might suffice to resolve concern b) about the high performance cost of flushing everything by default?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For clarity’s sake: When I recommended getting rid of Clipboard.Flush() I was mainly talking about the public method, to avoid confusion. @andrew-z raises a good point re performance; I did not think of that. I agree with @metathinker's idea about providing an auto-flushing opt out.

[marshaling_behavior(standard)]
static runtimeclass Clipboard
{
static DataPackageView GetContent();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I hope we can make this call more robust. It's not rare for this to fail with a set of "known" exceptions and then succeed on a retry. It would be a better developer experience if these retries are at least encapsulated and the call either fails or succeeds. Ideally, of course, if must never fail, as there is no obvious reason for not being able to read out from clipboard (if we forget old Win32 API design and such), even if it means that clipboard lock is forcefully re-taken by the caller.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Could we also try an approach where GetContent() returns a failure flag as well as the data package? By that, I mean something like this, where we define a GetContentResult record (following the pattern of GetHistoryItemsAsync() and ClipboardHistoryItemsResult):

runtimeclass GetContentResult {
  Boolean Succeeded { get; }
  DataPackageView ClipboardContent { get; }
}

static runtimeclass Clipboard {
  static GetContentResult GetContent();
}

or something like this, where GetContent() has an output parameter as well as a return value:

static runtimeclass Clipboard {
  static Boolean GetContent(out DataPackageView content);
}

The idea is that then the app developer will write his or her own retry loop if he/she really cares about obtaining the content to paste.

I skimmed our internal WinRT API Design Guidelines but I couldn't find any advice on how to surface errors in actions that - like reading the clipboard - can fail transiently, but complete quickly whether they succeed or fail. Probably I missed something though.

we could use use the Reunion SDK main package
to bring the cloud clipboard sync client to older Windows versions.

* _Copy append/paste multiple_: We've also seen several suggestions from
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

How is this different from clipboard history? 🤔

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

With the current version of the clipboard history UI, you still have to paste the various items you copied one at a time. With "copy append", you'd paste 10 fragments of text with one click or keystroke as a single long fragment.

Copy link
Copy Markdown
Author

@metathinker metathinker left a comment

Choose a reason for hiding this comment

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

responded to most comments so far

The only exception will be the change to make [Clipboard.Flush()](
https://docs.microsoft.com/uwp/api/windows.applicationmodel.datatransfer.clipboard.flush)
a completely optional operation.
(TO CONSIDER: Should Clipboard.Flush() be removed entirely?)
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It was suggested internally that we could combine auto-flushing by default with a way for apps to opt out of auto-flushing specified formats. Do you think that might suffice to resolve concern b) about the high performance cost of flushing everything by default?

[marshaling_behavior(standard)]
static runtimeclass Clipboard
{
static DataPackageView GetContent();
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Could we also try an approach where GetContent() returns a failure flag as well as the data package? By that, I mean something like this, where we define a GetContentResult record (following the pattern of GetHistoryItemsAsync() and ClipboardHistoryItemsResult):

runtimeclass GetContentResult {
  Boolean Succeeded { get; }
  DataPackageView ClipboardContent { get; }
}

static runtimeclass Clipboard {
  static GetContentResult GetContent();
}

or something like this, where GetContent() has an output parameter as well as a return value:

static runtimeclass Clipboard {
  static Boolean GetContent(out DataPackageView content);
}

The idea is that then the app developer will write his or her own retry loop if he/she really cares about obtaining the content to paste.

I skimmed our internal WinRT API Design Guidelines but I couldn't find any advice on how to surface errors in actions that - like reading the clipboard - can fail transiently, but complete quickly whether they succeed or fail. Probably I missed something though.

Comment on lines +149 to +152
static void SetContent(DataPackage content);
static Boolean SetContentWithOptions(DataPackage content, ClipboardContentOptions options);
static void Flush();
static void Clear();
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Per an internal reviewer: SetContent, Flush, and Clear can all fail for transient reasons, mainly when they fail to obtain the win32/user32 clipboard lock - the same issue that @andrew-z mentioned above for GetContent. Hence, all 3 should also be augmented with a better way to communicate these transient errors than throwing an exception from the projection.

we could use use the Reunion SDK main package
to bring the cloud clipboard sync client to older Windows versions.

* _Copy append/paste multiple_: We've also seen several suggestions from
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

With the current version of the clipboard history UI, you still have to paste the various items you copied one at a time. With "copy append", you'd paste 10 fragments of text with one click or keystroke as a single long fragment.

DataPackageOperation RequestedOperation;

event Windows.Foundation.TypedEventHandler<DataPackage, OperationCompletedEventArgs> OperationCompleted;
event Windows.Foundation.TypedEventHandler<DataPackage, Object> Destroyed;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I think you could be right, Andrew, but then that doesn't explain why we have to have both OperationCompleted and Destroyed. I'd argue that if Destroyed is for letting the source app know when it can release resources needed for data package rendering, then OperationCompleted is not useful at all for DataPackages on the clipboard. (You can't take any action if the data item copied from your app has just been pasted, because the user can paste it again as much as they like.)

I went back to the original Win8-era API spec for Data Package; the descriptions of the events (copied below) were still unclear to me:

  • OperationCompleted: "This event fires when a paste operation is completed."
  • Destroyed: "This event fires when the databpackage is destroyed." [sic]

Those descriptions, coupled with the existence of OperationCompletedEventArgs and its AcceptedFormatId property suggests to me that OperationCompleted was intended to be the "it's ok to free your temp memory/delete your temp files" event, which leads us back to the start of this thread: why have Destroyed?

DataPackageOperation RequestedOperation;

event Windows.Foundation.TypedEventHandler<DataPackage, OperationCompletedEventArgs> OperationCompleted;
event Windows.Foundation.TypedEventHandler<DataPackage, Object> Destroyed;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

(The original author of the Data Package specs, M.B., is now in a different division of Microsoft; I could ask him if he still remembers why he did what he did 9 or 10 years ago but I suspect he doesn't :)

String Description;
Windows.Storage.Streams.IRandomAccessStreamReference Thumbnail;
Windows.Foundation.Collections.IVector<String> FileTypes { get; };
String ApplicationName;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I like both your ideas. To adopt either, we'd have to make clear that the app identity and other metadata here are not suitable for making any security decisions, but I don't think anyone will have a problem with that decision.

/// a delay rendering, with the ability to signal the system when done.
/// </summary>
[contract(ClipboardContract, 1.0)]
runtimeclass DataProviderDeferral
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Hm, that sounds more like a general problem with the BlahDeferral concept than specific to clipboard or app-to-app data transfer. If you don't mind, could you fork that into a separate issue or discussion?

@metathinker
Copy link
Copy Markdown
Author

ping @microsoft/projectreunion-dev

@EHO-makai EHO-makai closed this Nov 11, 2020
@EHO-makai EHO-makai deleted the branch microsoft:master November 11, 2020 21:14
@michael-hawker
Copy link
Copy Markdown

I'm confused, where does this spec live now?

@metathinker
Copy link
Copy Markdown
Author

I'm confused, where does this spec live now?

Please ask my old program manager, @ieburk. I don't work on the clipboard anymore, or at Microsoft at all. I'm sorry I didn't ping the thread when the transition occurred; it must have slipped my mind.

I assume that this PR was auto-closed when the trunk branch of the repo got renamed from master to main. If clipboard undocking into the Reunion SDK is still in progress or will be revived, it might be worth reopening or copying this PR, but my interest in that is now no more than the rest of us :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants