File chooser contract: files.choose
Status: draft, version 0.1.0, ready for step 2 (implementation). Nothing here is served yet. Revised after reviews by Sol, Fable and GLM.
MixOS has one system file chooser. A person picks files or a save location in it; it never opens, reads or writes the files. It is reached two ways:
- Over the Bus, with
files.choose. Apps and agents call it directly; an agent uses it to ask a person to pick a file. - Over D-Bus, through
org.freedesktop.portal.FileChooser, whichportaldserves and forwards tofiles.choose. Every app that asks the desktop portal for a dialog uses this route, whatever its toolkit (rfd, GTK, Qt, Electron and Firefox).
app --D-Bus FileChooser.OpenFile--> portald --Bus files.choose--> chooser window
agent --Bus files.choose--------------------------^ |
<---- outcome on the files.chosen topic, or files.choose.result
Provider
The Bus service files is served by DOpus in chooser mode,
dopus --choose-service, a session unit started with the desk. It has no main
window. Each accepted files.choose opens one chooser window: a single DOpus
pane with Places, a path bar, a file-name field when saving, a filter menu,
the caller's choices, and Accept and Cancel. Several requests open several
windows. Requests keep no state from one to the next, apart from the
Places, bookmarks and recent folders that the file manager also uses.
The unit restarts on failure, as portald@.service does, so a crash costs
the open requests but not the next dialog. The chooser process serves
files.* and the drive commands only, never dopus.*; DOpus's registry test
must check each mode's command set separately.
Each start of the provider is an incarnation, named by a random 64-bit value in lower-case hex. Requests belong to the incarnation that accepted them and are lost with it.
Commands
Owner dopus. They enter docs/docs/spec/bus/commands.conf.mix (status
draft; classes as given below) with the code that serves them, because
DOpus's registry test requires the registry to list exactly what DOpus
serves.
files.choose (intent)
Opens a chooser and answers straight away; the person's answer comes later (see Outcome). Body:
| Field | Type | Default | Meaning |
|---|---|---|---|
mode | open, save or save_files | open | What the person is choosing. |
title | string, 1 to 256 bytes | by mode | The window title. |
accept_label | string, 1 to 64 bytes | by mode | The Accept button's label. Mnemonic underscores are removed. |
multiple | bool | false | open only: more than one entry may be chosen. |
directory | bool | false | open only: choose folders, not files. |
filters | list of filter, at most 64 | none | open and save only. See Filters. |
current_filter | filter | first of filters | open and save only: the filter in effect at first. See Filters. |
choices | list of choice, at most 16 | none | See Choices. |
current_folder | location | last folder used, else home | The folder shown first. |
current_name | name | none | save only: the suggested file name. |
current_file | location | none | save only: an existing file to save over; its folder and name are shown. Takes precedence over current_folder and current_name. |
files | list of name, 1 to 256, unique | none | save_files only, and required: the names of the files about to be saved into the chosen folder. |
parent_window | string, at most 256 bytes | "" | The window the chooser belongs to. See Placement. |
correlation | string, 1 to 256 bytes | none | Opaque to the provider; echoed in the reply, the outcome and files.choose.list. |
on_behalf_of | string, 1 to 128 bytes | none | The app a forwarding service asks for. portald sends <unique bus name> <process name>, for example :1.42 fakeapp. Shown to the person and used for limits and the remembered folder; it is not an authority. |
A location is an absolute path written as a UTF-8 string, or a file://
URI (RFC 8089, no host or localhost) whose percent-encoding carries the
exact bytes of a path. The path, decoded, is at most 4,096 bytes with no NUL
byte. Nothing else is accepted.
A name is one path component: a UTF-8 string, or {"bytes": "<percent-encoded>"}
for a name that is not UTF-8. Decoded, it is 1 to 255 bytes, with no / and
no NUL, and is neither . nor ...
Unknown fields are ignored. A known field of the wrong type or out of range,
a field used in the wrong mode, save_files without files, duplicate names
in files, or a body over 256 KiB is refused with ARGUMENT.
Reply, rc 0:
{"request": "9f2c41d07ab3e655-17", "topic": "files.chosen", "correlation": "/org/freedesktop/portal/desktop/request/1_42/rfd1"}
request is <incarnation>-<sequence>, made by the provider. It is never
reused: the sequence only grows within an incarnation, and a new incarnation
has a new prefix. correlation is present when the caller gave one.
Refusals open no window and keep nothing:
BUSYwhen 8 choosers are already open on the desk; when 2 are open for this caller; or when this caller has made more than 10 requests in the last 10 seconds. The caller for both limits is the pair (broker connection,on_behalf_of) whenon_behalf_ofis given, else the broker connection alone, so the apps behind portald are limited one by one;UNAVAILABLEwhen the chooser window cannot be opened (no display, for example).
Shape. A person can take longer than the Bus client waits for a reply
(60 s), and a topic is published only by the service that owns it, so the
answer cannot be a held reply or a topic the caller names. The shape is the
one shell.execute, shell.execute.result and shell.execute.cancel already
use: an immediate reply, an owner-published event and a pullable result.
Ordering. A caller subscribes to files.chosen before it calls
files.choose, or reads files.choose.result instead; a caller on another
node uses the result query. The provider sends the reply before the window
accepts any input, but the reply and the topic are separate deliveries, so a
caller keeps for a short while any outcome naming a request it has not yet
been told about. Once the reply is in, outcomes are matched by request
alone; correlation only links a parked outcome to a reply, and is applied
only when the outcome's request equals the reply's.
files.choose.result (query)
Body {request}. Answers with the request's state:
{state: "open"}: the person has not answered;{state: "done", outcome: {...}}: the outcome, exactly as published;ARGUMENTwith{"reason": "unknown"}for a request this incarnation never made (another incarnation's included), or{"reason": "expired"}for one whose outcome is no longer kept.
Outcomes are kept for 10 minutes after they are decided, at most 64 of them; when a 65th is decided, the oldest is dropped first. Reading an outcome does not remove it. This command is for a caller that missed the publication.
files.choose.cancel (intent)
Body {request}. Closes an open chooser; its outcome is response 1 with
reason: "caller". Idempotent: a request already done, expired, never made
by this incarnation, or made by an earlier one answers rc 0 and changes
nothing. A malformed request is ARGUMENT.
Any caller may cancel any request, as the open Bus requires
(docs/docs/decisions/2026-10-26-open-bus-for-agents.md); drive input can
press Cancel in the window anyway. It is also how choosers left by a caller
that has gone are cleared before noded's connection-closed notice exists.
files.choose.list (query)
Body none. Answers
{incarnation, requests: [{request, mode, title, caller, on_behalf_of, correlation, opened_ms}]}
for every open chooser, oldest first. caller is citizen::caller_key of
the connection that asked.
Outcome
When a request ends, one outcome is published once on topic files.chosen
and kept for files.choose.result:
{
"request": "9f2c41d07ab3e655-17",
"correlation": "/org/freedesktop/portal/desktop/request/1_42/rfd1",
"response": 0,
"reason": "chosen",
"uris": ["file:///home/user/Pictures/beach.png"],
"current_filter": {"name": "Images", "patterns": ["*.png", "*.jpg"], "mime_types": []},
"choices": {"encoding": "utf8", "reencode": "true"}
}
response | reason | When |
|---|---|---|
| 0 | chosen | The person accepted. |
| 1 | cancelled | The person pressed Cancel or Escape, or closed the window. |
| 1 | caller | files.choose.cancel, or the caller's broker connection closed. |
| 2 | failed | The chooser failed after it opened. message says why. |
Each request has exactly one outcome; the first end wins and later ones are dropped. Response codes are the portal's, so portald passes them on unchanged.
urisarefile://URIs, percent-encoded from the exact bytes of each path. Onopen, there is one unlessmultipleis set, never none. Onsave, there is exactly one. Onsave_files, there is one per name infiles, in the same order, each exactly<chosen folder>/<name>; the provider checks this before it accepts.current_filteris the filter in effect when the person accepted. It is present whenever a filter was in effect, fromfiltersor fromcurrent_filteralone.choicesmaps every choice'sidto its value: an option id, or"true"or"false"for a check box.uris,current_filterandchoicesare present only whenresponseis 0.
The topic is published by files alone. Every subscriber sees every outcome;
the Bus is open (docs/docs/decisions/2026-10-26-open-bus-for-agents.md),
and an outcome carries paths, never file contents.
Filters
A filter is {name, patterns, mime_types}: a label of 1 to 128 bytes, and
together 1 to 512 entries in two lists of strings of 1 to 256 bytes (an "All
images" filter can list hundreds):
patterns: shell globs (*,?,[...]), matched against the file name, case sensitive;mime_types: MIME types ortype/*, matched by the type the shared MIME database gives the file.
current_filter alone, with no filters, is the one filter in effect and the
filter menu is hidden. With filters, a current_filter equal to one of them
(same name and lists) selects it; one that is not equal to any is added at the
top of the menu and selected. Folders are always shown, so the person can
move between them. When save is used with a filter whose only pattern is
*.ext, a typed name without an extension gets that one.
Choices
A choice is {id, label, options, default}:
idandlabelare strings of 1 to 64 and 1 to 128 bytes; ids are unique;optionsis a list of up to 32{id, label}with unique ids. When it is empty the choice is a check box;defaultis optional. For a list it is one of the option ids, and the first option when absent. For a check box it is"true"or"false", and"false"when absent.
They are shown under the file list, in order.
Behaviour
- No timeout. A chooser waits for the person as long as it takes.
- Save. When the chosen file exists, the person confirms replacing it
before Accept succeeds.
save_filesdoes the same once for all the names that exist; unlike some portal backends it never renames to avoid a collision, so each URI is exactly<folder>/<name>. The chooser never creates, truncates or replaces anything; the caller does that with the URIs it receives. - Typed names. A typed save name containing
/moves to that folder when it exists, as a path bar would; otherwise Accept is refused in the window with a message. - Remembered folder. The last folder accepted is kept per
on_behalf_of, else per caller, and is where the next chooser for it opens when nocurrent_folderorcurrent_fileis given. - Who is asking. When the caller is not
portald, the window names the caller bycitizen::caller_key, so a person can tell an agent's request from an app's.on_behalf_ofis the caller's claim, not an identity: it is shown as a claim ("for fakeapp, says portald"), visibly apart from the caller's name, and never in its place. - Drive. Each chooser window answers the toolkit drive commands
(
docs/docs/drive.md) asfiles.ui.*andfiles.window*, with the request id as theviewport. With more than one chooser open, a drive command withoutviewportisARGUMENT. They are registered with the code. - Caller leaves. When the caller's broker connection closes, its open
choosers close with response 1,
reason: "caller". This needs a notice from noded that a connection closed, which does not exist yet (see Losing a party). - Provider stops. If
filesstops, every open request is lost with its incarnation; a new incarnation knows nothing of them. Nothing is published for a lost request, so the topic is not a liveness guarantee: a caller that must not wait forever checksfiles.choose.result(unknownmeans lost) whenfilesunregisters, or on a deadline of its own.
Losing a party
Two notices are needed, and noded publishes neither today. Step 2 adds both to noded as owner-published topics: a service registered or unregistered, and a connection closed. Until they land, v1 does this, and the stopgaps are removed when they do:
- Provider lost, or an outcome missed (portald's view). While at least
one Request is not ended, portald reads
files.choose.listevery 5 seconds. An error or a changedincarnationmeansfileswas lost. An open Request whoserequestis missing from the list, with the incarnation unchanged, was decided and its publication missed: portald readsfiles.choose.resultand sends theResponse(done), orResponse2 (unknown). This is a bounded, temporary exception to the no-polling rule: it runs only while a person has a dialog open. - portald lost (the provider's view). At start, portald reads
files.choose.listand cancels every request whosecorrelationis a portal Request path (/org/freedesktop/portal/desktop/request/…): none of them can belong to the new portald. - Other callers lost. Their choosers stay open until the person answers or anyone cancels them; the outcome is published and kept as usual.
Placement
parent_window is the portal's window identifier: "", wayland:<handle>
(an exported xdg-foreign handle) or x11:<hex id>. In this version the
chooser opens centred on the output of the parent window when compd can
resolve the identifier, otherwise on the focused output, and stays above the
parent. It is not modal to the parent; that needs xdg-foreign import and
xdg-dialog-v1 support in compd, a later step.
D-Bus: org.freedesktop.portal.FileChooser
portald serves the interface at version 4 on the object it already serves
(see ../portal/README.md), for trusted unsandboxed callers in the session.
OpenFile(s parent_window, s title, a{sv} options) -> o, SaveFile with the
same arguments, and SaveFiles with the same arguments all return a Request
object straight away, at
/org/freedesktop/portal/desktop/request/<sender>/<handle_token>, built as
for ScreenCast: handle_token is 1 to 64 of A-Z a-z 0-9 _, else the call
is refused with InvalidArgument; a token whose path already holds a live
Request from the same sender is refused the same way. Then portald calls
files.choose with the mapped body, correlation set to the Request's
object path, and on_behalf_of set to the caller's unique bus name and
process name, the process name cut to fit 128 bytes.
| Portal | files.choose |
|---|---|
method OpenFile / SaveFile / SaveFiles | mode open / save / save_files |
parent_window argument | parent_window |
title argument | title (the mode's default when empty) |
handle_token (s) | not sent: it names the Request path only |
accept_label (s) | accept_label |
modal (b) | ignored: not modal in this version |
multiple (b) | multiple |
directory (b) | directory |
filters (a(sa(us))) | filters: kind 0 to patterns, kind 1 to mime_types; not sent for SaveFiles |
current_filter ((sa(us))) | current_filter, mapped the same way; not sent for SaveFiles |
choices (a(ssa(ss)s)) | choices: (id, label, [(id, label)], default); an empty default is left out |
current_name (s) | current_name |
current_folder (ay) | current_folder as a percent-encoded file:// URI |
current_file (ay) | current_file as a percent-encoded file:// URI |
files (aay, SaveFiles) | files, each the last component of the entry (as other portal backends do); {"bytes": ...} when not UTF-8 |
Byte strings (ay) carry exactly one trailing NUL, which is removed; a byte
string without it, or with a NUL anywhere else, is refused. Other options are
ignored.
Lenient at the D-Bus edge. rfd and others show any portal error as a
cancel, so a refusal is a dialog that silently never appears. portald
therefore refuses only a known option with the wrong D-Bus type, a NUL
violation, or SaveFiles with no usable files, with
org.freedesktop.portal.Error.InvalidArgument at the call and no Request.
Everything else is made to fit: an option the method's mode does not take
(multiple or directory on a save, for example) is not sent; a location or
name that cannot be made valid is not sent; duplicate files names keep the
first; an empty string becomes the field's default;
a label over its limit is cut at a character boundary; a list over its limit
keeps its first entries; an invalid entry (a pattern over 256 bytes, a choice
with a duplicate id) is dropped. portald logs each adjustment.
The outcome becomes Response(u response, a{sv} results), addressed to the
Request's owner alone:
| Outcome | Response |
|---|---|
| 0 | 0, with uris (as), choices (a(ss)) when the call had choices, current_filter ((sa(us))) when a filter was in effect |
| 1 | 1, {} |
2, or files.choose refused (BUSY, UNAVAILABLE, any error), or files not registered, or lost | 2, {} |
Request lifecycle
Each portal Request is in one of four states. Every transition happens under one lock, and a Request leaves the table only when it is ended.
| State | Entered when | Close (owner) or owner leaves | Outcome for it arrives | files.choose answers | Bus or files lost |
|---|---|---|---|---|---|
| sending | the method returns the Request path, before files.choose is sent | to closing | parked by correlation until the reply | ok: to open, then apply a parked outcome whose request matches; refused: Response 2, ended; no reply in time: Response 2, to closing | Response 2, ended |
| open | files.choose answered with a request id | send files.choose.cancel; ended, no Response | matched by request: Response as mapped, ended | — | Response 2, ended |
| closing | Close came while sending, or the reply timed out | — | dropped | ok: send files.choose.cancel, ended; refused: ended | ended |
| ended | a Response was sent, or Close was handled | — | dropped | dropped | — |
- portald subscribes to
files.chosenbefore it serves FileChooser, and again on every reconnect to the Bus. - A Request in closing that never gets a reply is also swept: portald
looks for its
correlationinfiles.choose.list, cancels any match, and ends it. Closefrom anyone but the owner is refused withorg.freedesktop.portal.Error.NotAllowed.- A lost connection is final. When portald loses the Bus, or
filesis lost (see Losing a party), every Request not yet ended getsResponse2 and ends. After reconnecting, portald does not try to recover them; it cancels their choosers as described there. - A Request is never left without an end, so an app waiting on it can tell
failure from cancel. A
Responseis sent at most once, and never afterClose.
Acceptance
- Over the Bus alone, under a nested compd: open, save (with replace
confirmation) and save_files, each accepted, cancelled by the person,
cancelled by the caller, and with the caller's connection dropped. Filters
(including a standalone
current_filter) and choices (including an absent default) round-trip. A path and asave_filesname that are not UTF-8 round-trip. Names..,a/b, an empty name and duplicates are refused. Request ids are not reused across a provider restart. The limits and the outcome eviction refuse and evict as written. - Over D-Bus, with a persistent zbus caller fixture (a one-shot
gdbus calldisconnects before theResponse): each method; two apps using the samehandle_tokenat once; three apps with dialogs open at once (noneBUSY);Request.Closewhile sending and while open (noResponse, no chooser left open), then the samehandle_tokenreused at once (the new Request gets only its own outcome); the owner disconnecting; afiles.choosereply that times out (Response2, and the late window is cancelled);filesabsent, and killed mid-request (Response2 within 10 s); an emptyaccept_labeland a 300-pattern filter (accepted, adjusted); andClosefrom another sender (refused). - A craft app built with rfd 0.17, on VT3: File > Open and File > Save As show the chooser; the file is opened and saved; Cancel leaves the app as it was.
Settled questions
- FileChooser version 4. Checked against upstream: every mapped option
exists at version 4 (
directorysince 3);SaveFilestakes nofiltersorcurrent_filter. - Drive with several windows: the request id is the drive
viewport(see Behaviour). - MixOS's own apps (Email's Attach files) move to
files.chooseas a task of their own once acceptance 3 passes.toolkit::file_chooserstays only for whenfiles.chooseisUNAVAILABLEorfilesis absent. - Connection-closed notice: v1 does not wait for it (see Losing a party); noded gains the two topics in step 2.