406. A uri-list is a list of names, and only some of them are files
Date: 2026-09-19
Status
Accepted. Closes the “No file lists” entry under “The clipboard” in
book/src/TODO.md.
Extends ADR-0286, which put bytes under a MIME type on the clipboard and deliberately stopped there, and ADR-0330, whose rule about a name the file system will not accept is reused here.
SDL_EVENT_DROP_TEXT is not bound here. The reason is recorded below, because
it is not the reason ADR-0330 gave — and it was paid and bound immediately
afterwards by
ADR-0408,
once another change in the same batch made the native half’s bill payable.
Context
The entry:
text/uri-listis bytes like anything else and works today, but nothing turns those bytes into paths.
Both halves of that are true and the second is the whole cost. A uri-list is
not a list of paths; it is a list of percent-encoded URIs, and the twenty lines
that turn one into the other are lines every application would write for itself.
They are also lines that are easy to get wrong in a way nothing notices: the
naive reader — split on newlines, chop off file:// — is correct for every file
in a test fixture and wrong for the first one with a space in its name, which
arrives as /tmp/my%20file.png and is a file that does not exist. That bug does
not fail a build. It ships, and it is reported as “dragging from Downloads does
not work” by a user whose Downloads folder has a space in one name.
The format is RFC 2483’s: one URI per line, CRLF between lines, # starts a
comment. What actually arrives is looser than that — a bare LF is common, a
trailing NUL happens on X11 — and none of the looseness is the interesting part.
The interesting part is that a uri-list is not a file list. A drag out of a
browser is a list of https: URIs; a mail client offers mailto:. Deciding what
happens to those is the decision this ADR exists to record.
Decision
A record in the clipboard’s own package, holding URIs, with the file half as a conversion.
// io.github.digitalsmile.goldberry.render
public record UriList(List<URI> uris) {
public static final String MIME = "text/uri-list";
public static UriList parse(byte[] bytes);
public static UriList parse(String text);
public static UriList of(List<Path> paths);
public List<Path> paths();
public String text();
public byte[] encode();
public static boolean onClipboard(Clipboard clipboard);
public static UriList fromClipboard(Clipboard clipboard);
public boolean toClipboard(Clipboard clipboard);
}
It lives beside Clipboard, unlike Image
ADR-0286 put the image convenience beside the decoder and argued why: a backend
implementing the SPI must not have to know what a PNG is, and putting Image on
Clipboard would have made render depend on image, which depends on render.
That argument does not reach this type, and it is worth saying why rather than
applying it by analogy. UriList has no decoder to live beside: its only
dependency is java.nio.file, which is the JDK. It drags nothing into the SPI,
it forces nothing onto any backend — it is a value, like Cursor and
DamageRect in the same package — and the alternative is not “a smaller
render” but “every application parsing this itself”. Clipboard’s own javadoc
names it now, one line below where it names Image.fromClipboard, so a reader
who has the bytes finds the type that reads them.
Non-file: entries are kept, and are not paths
uris() is every entry that parsed. paths() is the file: subset, converted.
Filtering the others out at parse time would be cheaper and it would throw away
the answer to the question a failing paste actually asks. An application that
asked for files and got none needs to know whether the clipboard was empty or was
offering a list of web links, and those are the same answer if the web links were
silently dropped. This is the argument Clipboard.types() is on the interface
for (ADR-0286), one level up.
So a paste of a browser’s drag is a UriList with two URIs and no paths, and an
application can say so.
A line that is not a URI is dropped, with a log
Not thrown. The list came from another application across a protocol with no
schema and no validator, and ADR-0330 already decided this for the names that
arrive by drag-and-drop from the same desktops: a drop shortened by one bad name
is better than a drop that failed. A parse that threw would turn one bad line
into a paste that does nothing, and the bad line is usually the tenth of ten.
Three kinds of entry are dropped, and each is a separate judgement:
- Not a URI at all.
file:///tmp/%ZZis a malformed escape pair andfile:///tmp/a bhas a raw space;URI’s own parser refuses both. - No scheme. A bare
/tmp/xis dropped rather than read as a path. Guessing here is howC:\Users\…written by another machine becomes a name this one would happily create, and a line without a scheme is, by the format’s own definition, not an entry. - A name this file system refuses.
file:///tmp/a%00bis a legal URI:%00is a well-formed escape and NUL is a byte. It is not a legal path, andPath.ofsays so. This is exactly the case ADR-0330 handles for drops, arriving through the other door, and it gets the same answer.
file://localhost/x is this machine
RFC 8089 blesses both file:///x and file://localhost/x, and Java’s file
system accepts only the first: Path.of(URI.create("file://localhost/tmp/a"))
throws IllegalArgumentException: URI has an authority component. Dropping that
entry would be the toolkit inventing a failure the desktop did not have, so the
one authority that is this machine is normalised away.
Any other authority is left to fail. file://fileserver/share/a.png names
something on another host; resolving it to /share/a.png here would open the
wrong file, and opening the wrong file is worse than opening none.
Read loosely, write strictly
Reading accepts CRLF, LF, a bare CR, blank lines, # comments and a trailing
NUL, and decodes as UTF-8 — which is what percent-decoding a file: URI
produces on every desktop this runs on. Writing emits RFC 2483’s form: one entry
per line, CRLF-terminated, percent-encoded by Path.toUri. The half of the
protocol this toolkit controls is the half it can afford to be strict about.
fromClipboard is empty rather than Optional
Clipboard.text() already argued this: “there is nothing to paste” and “what was
copied was empty” are the same paste, and a caller that had to distinguish them
would have nothing different to do. Image.fromClipboard returns an Optional
because a decode can fail; nothing here can. An application that does care asks
onClipboard first, which is the cheap question.
SDL_EVENT_DROP_TEXT is still unbound, and now for a different reason
ADR-0330 left it out because nothing had asked for it — “a second event with no caller is a second event with no test”. That is still true, and it is no longer the binding constraint. The constraint is that the event number is a verified constant, and the verification lives in C.
Every value of SdlEventType is entered into NativeConstants.registry() by a
loop over values(), and LayoutVerifier reports any registered constant that
goldberry_shim.c does not report back:
SDL_EVENT_DROP_TEXT is declared in Java but not registered in goldberry_shim.c, so nothing verifies it
That is the design working (ADR-0010): a hard-coded event number that nothing
checks is a binding that silently never fires. It also means adding
DROP_TEXT(0x1001) to the enum is not a Java-side change. It needs one more
GB_CONSTANT line in the shim, which is a native rebuild on four platforms and
an ABI version bump — the same bill ADR-0330 paid to take the ABI to 11 for the
other four drop events.
So the work is recorded rather than done: one line of C, one version number, and
then the DROP_TEXT arm in Sdl3Backend, a BackendEvent case and a
Window.onTextDrop shaped exactly like onFileDrop. Building the Java half now
and leaving the constant out would produce API that no platform can ever raise —
surface with no test, which is the thing ADR-0330 refused in the first place.
Consequences
text/uri-listis readable in five lines of application code:UriList.fromClipboard(clipboard).paths(). Writing one isUriList.of(paths).toClipboard(clipboard).UriListTestholds every decision above as a named case — 20 of them, includingpercentDecodes,aBarePathIsNotAUri,anUnparseableLineIsSkipped,aNulByteInTheNameIsSkipped,localhostIsThisMachineandanotherHostIsNotLocal. The three that matter are the last three: they are the cases that came from running the conversion rather than from reading the RFC.Path.ofis stricter than RFC 8089 and than the desktops, which was not obvious until it threw. That asymmetry is now in one place instead of in every application.- Nothing in
:nativeschanged, and no native rebuild is needed for this half. The drop-text half cannot be landed without one. FileDropandUriListstay separate types. They are the same information from two different platform mechanisms — a gesture versus a clipboard offer — and ADR-0330’s reason forFileDropcarrying a position is the reason they do not merge: a drop landed somewhere and a paste did not.
Alternatives considered
List<Path> paths()as the only accessor, dropping non-file URIs at parse. Shorter, and it destroys the evidence a failing paste needs.- Guess that a scheme-less line is a local path. Tolerant of one real producer and wrong about Windows names that came from elsewhere, where the guess resolves to a path this machine would create rather than find.
- Throw on an unparseable line. Makes one bad entry in a list of ten into a paste that does nothing, and the toolkit is not the validator of another application’s output.
- Percent-decode by hand.
URIandPath.ofalready do it, including the UTF-8 that%D0%BFis, and a hand-rolled decoder is a second place for the same bug. - Put the type in
input.dropbesideFileDrop. That package is input events; this is a value on a clipboard, and a paste is not an event. - Add
SDL_EVENT_DROP_TEXTtogoldberry_shim.chere. One line, and it makes this a native change: a rebuild on four platforms, a new ABI version, and a verification matrix, for an event with no caller. It belongs in whatever change first needs dropped text.